[{"data":1,"prerenderedAt":2659},["ShallowReactive",2],{"navigation":3,"/blog/fixing-gnome-google-drive-mounts-on-nixos-25-11":59,"visible-blog-posts":367},[4],{"title":5,"path":6,"stem":7,"children":8,"page":34},"Blog","/blog","blog",[9,13,35,39,43,47,51,55],{"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":34},"Case Studies","/blog/case-studies","blog/case-studies",[18,22,26,30],{"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},"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":36,"path":37,"stem":38},"ChronoCal: Time tracking inside Google Calendar","/blog/chronocal","blog/chronocal",{"title":40,"path":41,"stem":42},"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":44,"path":45,"stem":46},"From Nuxt to Hugo and Back Again","/blog/from-nuxt-to-hugo","blog/from-nuxt-to-hugo",{"title":48,"path":49,"stem":50},"The Intern is downloading movies again","/blog/intern-movie-torrent","blog/intern-movie-torrent",{"title":52,"path":53,"stem":54},"Japanese input in openSUSE Tumbleweed's KDE","/blog/japanese-input","blog/japanese-input",{"title":56,"path":57,"stem":58},"Jhey Pi: an official landing site for an aspiring musician","/blog/jp-landing","blog/jp-landing",{"id":60,"title":40,"author":61,"body":67,"category":353,"client":354,"date":355,"description":356,"extension":357,"featured":34,"hidden":34,"image":86,"meta":358,"minRead":161,"navigation":359,"ogImage":354,"outcome_headline":354,"path":41,"repoUrl":354,"role":354,"seo":360,"stack":354,"stem":42,"tags":361,"team_size":354,"type":354,"year":354,"__hash__":366},"blog/blog/fixing-gnome-google-drive-mounts-on-nixos-25-11.md",{"name":62,"username":63,"to":64,"avatar":65},"Iván Álvarez","ivanovertime","https://github.com/ivanovertime",{"src":66,"alt":62},"/avatar.jpg",{"type":68,"value":69,"toc":342},"minimark",[70,74,80,87,90,95,98,111,114,118,121,126,129,165,168,172,175,206,209,213,216,236,239,242,249,252,256,259,279,282,293,296,300,307,310,314,317,335,338],[71,72,73],"p",{},"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:",[75,76,77],"blockquote",{},[71,78,79],{},"\"Location is not mountable\"",[71,81,82],{},[83,84],"img",{"alt":85,"src":86},"GNOME Files showing the \"Unable to access — Location is not mountable\" error dialog","/blog/fixing-gnome-google-drive-mounts-on-nixos/featuredImage.png",[71,88,89],{},"This post shares the fix that worked for me and got Drive mounts working reliably on my system.",[91,92,94],"h2",{"id":93},"the-problem","The problem",[71,96,97],{},"At first glance, everything looked correct:",[99,100,101,105,108],"ol",{},[102,103,104],"li",{},"GNOME Online Accounts was enabled.",[102,106,107],{},"I could sign in to Google.",[102,109,110],{},"Drive showed up in the sidebar.",[71,112,113],{},"But the mount itself still failed. The missing piece was that the default GVFS build in this setup did not include Google backend support.",[91,115,117],{"id":116},"what-i-changed","What I changed",[71,119,120],{},"I made three focused changes.",[122,123,125],"h3",{"id":124},"_1-force-a-gvfs-build-with-google-support","1) Force a GVFS build with Google support",[71,127,128],{},"I overrode the GVFS package to enable Google support explicitly:",[130,131,136],"pre",{"className":132,"code":133,"language":134,"meta":135,"style":135},"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","",[137,138,139,147,153,159],"code",{"__ignoreMap":135},[140,141,144],"span",{"class":142,"line":143},"line",1,[140,145,146],{},"services.gvfs.package = pkgs.gnome.gvfs.override {\n",[140,148,150],{"class":142,"line":149},2,[140,151,152],{},"  gnomeSupport = true;\n",[140,154,156],{"class":142,"line":155},3,[140,157,158],{},"  googleSupport = true;\n",[140,160,162],{"class":142,"line":161},4,[140,163,164],{},"};\n",[71,166,167],{},"Why: without this, GOA can authenticate, but GVFS cannot complete the Drive mount backend path.",[122,169,171],{"id":170},"_2-keep-the-gnome-accountmount-stack-enabled","2) Keep the GNOME account/mount stack enabled",[71,173,174],{},"I ensured these services stayed enabled:",[130,176,178],{"className":132,"code":177,"language":134,"meta":135,"style":135},"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",[137,179,180,185,190,195,200],{"__ignoreMap":135},[140,181,182],{"class":142,"line":143},[140,183,184],{},"services.gnome.gnome-online-accounts.enable = true;\n",[140,186,187],{"class":142,"line":149},[140,188,189],{},"services.gvfs.enable = true;\n",[140,191,192],{"class":142,"line":155},[140,193,194],{},"services.gnome.gnome-keyring.enable = true;\n",[140,196,197],{"class":142,"line":161},[140,198,199],{},"services.accounts-daemon.enable = true;\n",[140,201,203],{"class":142,"line":202},5,[140,204,205],{},"programs.dconf.enable = true;\n",[71,207,208],{},"Why: these are all part of the GNOME identity, credential, and desktop integration chain used for online volumes.",[122,210,212],{"id":211},"_3-allow-the-insecure-libsoup2-package-required-by-this-backend-path","3) Allow the insecure libsoup2 package required by this backend path",[71,214,215],{},"I added:",[130,217,219],{"className":132,"code":218,"language":134,"meta":135,"style":135},"nixpkgs.config.permittedInsecurePackages = [\n  \"libsoup-2.74.3\"\n];\n",[137,220,221,226,231],{"__ignoreMap":135},[140,222,223],{"class":142,"line":143},[140,224,225],{},"nixpkgs.config.permittedInsecurePackages = [\n",[140,227,228],{"class":142,"line":149},[140,229,230],{},"  \"libsoup-2.74.3\"\n",[140,232,233],{"class":142,"line":155},[140,234,235],{},"];\n",[71,237,238],{},"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.",[71,240,241],{},"Important note: this is a pragmatic compatibility workaround, not a long-term ideal from a security perspective.",[71,243,244,245,248],{},"From what nixpkgs currently documents, this is not about a single isolated CVE. The ",[137,246,247],{},"libsoup 2"," line is end-of-life and marked with many known unfixed CVEs, so Nix surfaces it as insecure by default.",[71,250,251],{},"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.",[91,253,255],{"id":254},"apply-and-verify","Apply and verify",[71,257,258],{},"After updating your configuration:",[130,260,264],{"className":261,"code":262,"language":263,"meta":135,"style":135},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","sudo nixos-rebuild switch\n","bash",[137,265,266],{"__ignoreMap":135},[140,267,268,272,276],{"class":142,"line":143},[140,269,271],{"class":270},"sBMFI","sudo",[140,273,275],{"class":274},"sfazB"," nixos-rebuild",[140,277,278],{"class":274}," switch\n",[71,280,281],{},"Then:",[99,283,284,287,290],{},[102,285,286],{},"Open GNOME Online Accounts and confirm your Google account is connected.",[102,288,289],{},"Open Files.",[102,291,292],{},"Select Google Drive from the sidebar.",[71,294,295],{},"If everything is wired correctly, Drive should mount instead of failing with \"Location is not mountable.\"",[91,297,299],{"id":298},"tradeoffs-and-future-direction","Tradeoffs and future direction",[71,301,302,303,306],{},"This fix restores native GNOME Drive mounting, but it currently depends on a package exception (",[137,304,305],{},"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.",[71,308,309],{},"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.",[91,311,313],{"id":312},"tldr","TL;DR",[71,315,316],{},"If Google Drive appears in GNOME Files but will not mount on NixOS 25.11, this is the short version of what helped me:",[99,318,319,326,329],{},[102,320,321,322,325],{},"Override GVFS with ",[137,323,324],{},"googleSupport = true",".",[102,327,328],{},"Keep GOA, GVFS, keyring, accounts-daemon, and dconf services enabled.",[102,330,331,332,334],{},"Permit ",[137,333,305],{}," so the build works in current channel constraints.",[71,336,337],{},"I am thankful to the Nix maintainers and GNOME ecosystem here: even when this path is inconvenient, the defaults push us toward safer choices.",[339,340,341],"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);}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":135,"searchDepth":149,"depth":149,"links":343},[344,345,350,351,352],{"id":93,"depth":149,"text":94},{"id":116,"depth":149,"text":117,"children":346},[347,348,349],{"id":124,"depth":155,"text":125},{"id":170,"depth":155,"text":171},{"id":211,"depth":155,"text":212},{"id":254,"depth":149,"text":255},{"id":298,"depth":149,"text":299},{"id":312,"depth":149,"text":313},"article",null,"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.","md",{},true,{"title":40,"description":356},[362,363,364,365],"nixos","gnome","google-drive","gvfs","Nlie2Digskov25v2_yEhQ_F5egLrDABB1tgjsFRwnuM",[368,750,941,1102,1819,1965,2151,2443],{"id":369,"title":36,"author":370,"body":372,"category":731,"client":354,"date":732,"description":733,"extension":357,"featured":34,"hidden":34,"image":734,"meta":735,"minRead":736,"navigation":359,"ogImage":354,"outcome_headline":737,"path":37,"repoUrl":694,"role":738,"seo":739,"stack":740,"stem":38,"tags":745,"team_size":143,"type":731,"year":748,"__hash__":749},"blog/blog/chronocal.md",{"name":62,"username":63,"to":64,"avatar":371},{"src":66,"alt":62},{"type":68,"value":373,"toc":719},[374,378,381,384,387,402,406,409,423,427,430,434,440,451,456,467,472,483,488,496,500,503,511,517,523,529,533,536,539,556,560,563,580,584,591,602,678,681,685,688,698,701,716],[91,375,377],{"id":376},"situation","Situation",[71,379,380],{},"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.",[71,382,383],{},"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?",[71,385,386],{},"Most time-tracking solutions require either:",[388,389,390,393,396,399],"ul",{},[102,391,392],{},"A separate app you need to remember to open",[102,394,395],{},"A backend service to persist state",[102,397,398],{},"A third-party integration that adds OAuth complexity",[102,400,401],{},"A browser timer that depends on the tab staying active",[91,403,405],{"id":404},"problem","Problem",[71,407,408],{},"Calendar events are already a reliable source of truth for your time commitment. What if you could:",[388,410,411,414,417,420],{},[102,412,413],{},"Start/pause/stop tracking directly from the Calendar event card",[102,415,416],{},"Keep state in Apps Script UserProperties (no backend needed)",[102,418,419],{},"Optionally write the tracked duration back to the event description",[102,421,422],{},"Export sessions to Google Sheets for reporting or analysis",[91,424,426],{"id":425},"solution-chronocal","Solution: ChronoCal",[71,428,429],{},"ChronoCal is a Google Calendar add-on built entirely with Google Apps Script. It provides:",[122,431,433],{"id":432},"core-features","Core Features",[71,435,436],{},[437,438,439],"strong",{},"In-Event Tracking",[388,441,442,445,448],{},[102,443,444],{},"Open any calendar event on desktop Calendar and a contextual card appears",[102,446,447],{},"Buttons to start, pause, resume, and stop tracking from the card itself",[102,449,450],{},"Session state persists using Apps Script UserProperties—survives browser refresh and tab close",[71,452,453],{},[437,454,455],{},"Flexible Session States",[388,457,458,461,464],{},[102,459,460],{},"Running: timer is actively tracking",[102,462,463],{},"Paused: stopped but not finalized",[102,465,466],{},"Stopped: finalized session ready for export",[71,468,469],{},[437,470,471],{},"Event Description Integration",[388,473,474,477,480],{},[102,475,476],{},"Toggle option to write the final tracked duration directly to event descriptions",[102,478,479],{},"Handles recurring event instances correctly",[102,481,482],{},"Resolves the event across all calendars in your Google Account",[71,484,485],{},[437,486,487],{},"Google Sheets Export",[388,489,490,493],{},[102,491,492],{},"Export completed sessions to a Google Sheet for reporting, billing, or analysis",[102,494,495],{},"Minimal setup: the add-on can create the Sheet or append to an existing one",[122,497,499],{"id":498},"technical-approach","Technical Approach",[71,501,502],{},"The implementation is split across six core modules:",[130,504,509],{"className":505,"code":507,"language":508},[506],"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",[137,510,507],{"__ignoreMap":135},[71,512,513,516],{},[437,514,515],{},"No External Backend","\nEverything runs inside Google Apps Script. UserProperties store the session state, so it survives across browser sessions and tabs.",[71,518,519,522],{},[437,520,521],{},"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.",[71,524,525,528],{},[437,526,527],{},"Event Resolution","\nCorrectly handles recurring events and calendar resolution, so you can track time on the same event across multiple calendars without duplication.",[91,530,532],{"id":531},"why-this-approach","Why This Approach",[71,534,535],{},"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.",[71,537,538],{},"Apps Script is ideal for this because:",[388,540,541,544,547,550,553],{},[102,542,543],{},"Native access to Calendar and Sheets without additional APIs",[102,545,546],{},"UserProperties provide persistent client-side state",[102,548,549],{},"Contextual cards integrate directly into Calendar's UI",[102,551,552],{},"Zero infrastructure: no servers to maintain, no databases to back up",[102,554,555],{},"Tight integration with Google Workspace",[91,557,559],{"id":558},"current-state","Current State",[71,561,562],{},"ChronoCal is a functional MVP with pause/resume and Sheets export flows. Planned evolution includes:",[388,564,565,568,571,574,577],{},[102,566,567],{},"Keyboard shortcuts for faster access",[102,569,570],{},"Bulk session export options",[102,572,573],{},"Integration with other Workspace apps (Gmail, Tasks)",[102,575,576],{},"Analytics dashboard within Sheets",[102,578,579],{},"More granular state management (e.g., session notes, billing codes)",[91,581,583],{"id":582},"development-deployment","Development & Deployment",[71,585,586,587,590],{},"The repo includes a complete deployment workflow using ",[137,588,589],{},"clasp"," (Google Apps Script CLI). Prerequisites are minimal:",[388,592,593,596,599],{},[102,594,595],{},"Node.js and npm",[102,597,598],{},"Google account with Apps Script access",[102,600,601],{},"Optional: Nix + direnv (dev environment files included)",[130,603,605],{"className":261,"code":604,"language":263,"meta":135,"style":135},"direnv allow\nnpx @google/clasp login\nnpx @google/clasp create --type standalone --title \"ChronoCal\" --rootDir .\nnpx @google/clasp push\nnpx @google/clasp open\n",[137,606,607,615,626,660,669],{"__ignoreMap":135},[140,608,609,612],{"class":142,"line":143},[140,610,611],{"class":270},"direnv",[140,613,614],{"class":274}," allow\n",[140,616,617,620,623],{"class":142,"line":149},[140,618,619],{"class":270},"npx",[140,621,622],{"class":274}," @google/clasp",[140,624,625],{"class":274}," login\n",[140,627,628,630,632,635,638,641,644,648,651,654,657],{"class":142,"line":155},[140,629,619],{"class":270},[140,631,622],{"class":274},[140,633,634],{"class":274}," create",[140,636,637],{"class":274}," --type",[140,639,640],{"class":274}," standalone",[140,642,643],{"class":274}," --title",[140,645,647],{"class":646},"sMK4o"," \"",[140,649,650],{"class":274},"ChronoCal",[140,652,653],{"class":646},"\"",[140,655,656],{"class":274}," --rootDir",[140,658,659],{"class":274}," .\n",[140,661,662,664,666],{"class":142,"line":161},[140,663,619],{"class":270},[140,665,622],{"class":274},[140,667,668],{"class":274}," push\n",[140,670,671,673,675],{"class":142,"line":202},[140,672,619],{"class":270},[140,674,622],{"class":274},[140,676,677],{"class":274}," open\n",[71,679,680],{},"The project also includes contribution guidelines, PR templates, and issue templates for collaborators.",[91,682,684],{"id":683},"key-takeaway","Key Takeaway",[71,686,687],{},"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.",[71,689,690,691],{},"If you use Google Workspace and find yourself constantly switching between Calendar and time trackers, give ChronoCal a try: ",[692,693,697],"a",{"href":694,"rel":695},"https://github.com/ivanovertime/ChronoCal",[696],"nofollow","github.com/ivanovertime/ChronoCal",[699,700],"hr",{},[71,702,703,706,707,711,712],{},[437,704,705],{},"Image attribution:"," Featured image is from Outlook. Photo by ",[692,708,710],{"href":709},"https://unsplash.com/@edhardie?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText","Ed Hardie"," on ",[692,713,715],{"href":714},"https://unsplash.com/photos/a-computer-screen-with-a-calendar-on-it-4BnNnEtAGP0?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText","Unsplash",[339,717,718],{},"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":135,"searchDepth":149,"depth":149,"links":720},[721,722,723,727,728,729,730],{"id":376,"depth":149,"text":377},{"id":404,"depth":149,"text":405},{"id":425,"depth":149,"text":426,"children":724},[725,726],{"id":432,"depth":155,"text":433},{"id":498,"depth":155,"text":499},{"id":531,"depth":149,"text":532},{"id":558,"depth":149,"text":559},{"id":582,"depth":149,"text":583},{"id":683,"depth":149,"text":684},"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",{},6,"Published a functional MVP that brings real-time tracking directly into Google Calendar without external infrastructure","Engineer",{"title":36,"description":733},[741,742,743,744],"Google Apps Script","JavaScript","Google Calendar API","Google Sheets API",[746,747],"google-workspace","time-tracking",2026,"CD4RidCTooznvYxDm6V7M4RxoU4fY99kOBc-FTV_eks",{"id":60,"title":40,"author":751,"body":753,"category":353,"client":354,"date":355,"description":356,"extension":357,"featured":34,"hidden":34,"image":86,"meta":938,"minRead":161,"navigation":359,"ogImage":354,"outcome_headline":354,"path":41,"repoUrl":354,"role":354,"seo":939,"stack":354,"stem":42,"tags":940,"team_size":354,"type":354,"year":354,"__hash__":366},{"name":62,"username":63,"to":64,"avatar":752},{"src":66,"alt":62},{"type":68,"value":754,"toc":927},[755,757,761,765,767,769,771,779,781,783,785,787,789,809,811,813,815,839,841,843,845,861,863,865,869,871,873,875,887,889,897,899,901,905,907,909,911,923,925],[71,756,73],{},[75,758,759],{},[71,760,79],{},[71,762,763],{},[83,764],{"alt":85,"src":86},[71,766,89],{},[91,768,94],{"id":93},[71,770,97],{},[99,772,773,775,777],{},[102,774,104],{},[102,776,107],{},[102,778,110],{},[71,780,113],{},[91,782,117],{"id":116},[71,784,120],{},[122,786,125],{"id":124},[71,788,128],{},[130,790,791],{"className":132,"code":133,"language":134,"meta":135,"style":135},[137,792,793,797,801,805],{"__ignoreMap":135},[140,794,795],{"class":142,"line":143},[140,796,146],{},[140,798,799],{"class":142,"line":149},[140,800,152],{},[140,802,803],{"class":142,"line":155},[140,804,158],{},[140,806,807],{"class":142,"line":161},[140,808,164],{},[71,810,167],{},[122,812,171],{"id":170},[71,814,174],{},[130,816,817],{"className":132,"code":177,"language":134,"meta":135,"style":135},[137,818,819,823,827,831,835],{"__ignoreMap":135},[140,820,821],{"class":142,"line":143},[140,822,184],{},[140,824,825],{"class":142,"line":149},[140,826,189],{},[140,828,829],{"class":142,"line":155},[140,830,194],{},[140,832,833],{"class":142,"line":161},[140,834,199],{},[140,836,837],{"class":142,"line":202},[140,838,205],{},[71,840,208],{},[122,842,212],{"id":211},[71,844,215],{},[130,846,847],{"className":132,"code":218,"language":134,"meta":135,"style":135},[137,848,849,853,857],{"__ignoreMap":135},[140,850,851],{"class":142,"line":143},[140,852,225],{},[140,854,855],{"class":142,"line":149},[140,856,230],{},[140,858,859],{"class":142,"line":155},[140,860,235],{},[71,862,238],{},[71,864,241],{},[71,866,244,867,248],{},[137,868,247],{},[71,870,251],{},[91,872,255],{"id":254},[71,874,258],{},[130,876,877],{"className":261,"code":262,"language":263,"meta":135,"style":135},[137,878,879],{"__ignoreMap":135},[140,880,881,883,885],{"class":142,"line":143},[140,882,271],{"class":270},[140,884,275],{"class":274},[140,886,278],{"class":274},[71,888,281],{},[99,890,891,893,895],{},[102,892,286],{},[102,894,289],{},[102,896,292],{},[71,898,295],{},[91,900,299],{"id":298},[71,902,302,903,306],{},[137,904,305],{},[71,906,309],{},[91,908,313],{"id":312},[71,910,316],{},[99,912,913,917,919],{},[102,914,321,915,325],{},[137,916,324],{},[102,918,328],{},[102,920,331,921,334],{},[137,922,305],{},[71,924,337],{},[339,926,341],{},{"title":135,"searchDepth":149,"depth":149,"links":928},[929,930,935,936,937],{"id":93,"depth":149,"text":94},{"id":116,"depth":149,"text":117,"children":931},[932,933,934],{"id":124,"depth":155,"text":125},{"id":170,"depth":155,"text":171},{"id":211,"depth":155,"text":212},{"id":254,"depth":149,"text":255},{"id":298,"depth":149,"text":299},{"id":312,"depth":149,"text":313},{},{"title":40,"description":356},[362,363,364,365],{"id":942,"title":56,"author":943,"body":945,"category":731,"client":354,"date":1088,"description":1089,"extension":357,"featured":34,"hidden":34,"image":1090,"meta":1091,"minRead":155,"navigation":359,"ogImage":354,"outcome_headline":1092,"path":57,"repoUrl":1093,"role":738,"seo":1094,"stack":1095,"stem":58,"tags":1099,"team_size":143,"type":731,"year":748,"__hash__":1101},"blog/blog/jp-landing.md",{"name":62,"username":63,"to":64,"avatar":944},{"src":66,"alt":62},{"type":68,"value":946,"toc":1080},[947,949,952,956,959,963,974,978,1046,1050,1053,1056,1073,1077],[91,948,313],{"id":312},[71,950,951],{},"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.",[91,953,955],{"id":954},"context","Context",[71,957,958],{},"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.",[91,960,962],{"id":961},"constraints","Constraints",[388,964,965,968,971],{},[102,966,967],{},"Keep the experience lightweight and easy to update.",[102,969,970],{},"Merge Spotify releases, artist profile data, and YouTube videos into one feed.",[102,972,973],{},"Support bilingual copy without making the page feel crowded.",[91,975,977],{"id":976},"decisions","Decisions",[979,980,981,997],"table",{},[982,983,984],"thead",{},[985,986,987,991,994],"tr",{},[988,989,990],"th",{},"Decision",[988,992,993],{},"Trade-off",[988,995,996],{},"Chose",[998,999,1000,1011,1024,1034],"tbody",{},[985,1001,1002,1006,1009],{},[1003,1004,1005],"td",{},"Build a traditional promo site with separate pages for music and video",[1003,1007,1008],{},"More room for content, but harder to keep current",[1003,1010],{},[985,1012,1013,1016,1019],{},[1003,1014,1015],{},"Build a single landing site with a merged, auto-sorted feed",[1003,1017,1018],{},"Easier to scan and maintain; the artist updates in one place",[1003,1020,1021],{},[1022,1023],"decision-check",{},[985,1025,1026,1029,1032],{},[1003,1027,1028],{},"Keep the copy monolingual",[1003,1030,1031],{},"Simpler, but weaker reach for bilingual audiences",[1003,1033],{},[985,1035,1036,1039,1042],{},[1003,1037,1038],{},"Support ES/EN copy",[1003,1040,1041],{},"Better fit for the audience, with a little more content work",[1003,1043,1044],{},[1022,1045],{},[91,1047,1049],{"id":1048},"outcome","Outcome",[71,1051,1052],{},"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.",[71,1054,1055],{},"Feature-wise, the useful pieces were:",[388,1057,1058,1061,1064,1067,1070],{},[102,1059,1060],{},"Spotify releases and artist profile via Spotify API.",[102,1062,1063],{},"YouTube latest videos via the channel feed.",[102,1065,1066],{},"Auto-sorted, paginated feed with embeds.",[102,1068,1069],{},"Bilingual copy in ES/EN.",[102,1071,1072],{},"ISR caching for the feed endpoint.",[91,1074,1076],{"id":1075},"what-id-do-on-gcp-today","What I'd do on GCP today",[71,1078,1079],{},"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 Cloud Run or Firebase Hosting with preview environments per pull request.",{"title":135,"searchDepth":149,"depth":149,"links":1081},[1082,1083,1084,1085,1086,1087],{"id":312,"depth":149,"text":313},{"id":954,"depth":149,"text":955},{"id":961,"depth":149,"text":962},{"id":976,"depth":149,"text":977},{"id":1048,"depth":149,"text":1049},{"id":1075,"depth":149,"text":1076},"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":56,"description":1089},[1096,1097,1098],"Nuxt","Vue","Tailwind CSS",[1100,731],"nuxt","BWRpSOIbkkTSlj-wkJl21Uq4LTkKkKxd40uHB-ddkC4",{"id":1103,"title":31,"author":1104,"body":1106,"category":1797,"client":1798,"date":1799,"description":1800,"extension":357,"featured":359,"hidden":34,"image":1801,"meta":1802,"minRead":354,"navigation":359,"ogImage":1801,"outcome_headline":1803,"path":32,"repoUrl":354,"role":1804,"seo":1805,"stack":1806,"stem":33,"tags":1812,"team_size":149,"type":1816,"year":1817,"__hash__":1818},"blog/blog/case-studies/supermarket-pwa-recovery.md",{"name":62,"username":63,"to":64,"avatar":1105},{"src":66,"alt":62},{"type":68,"value":1107,"toc":1783},[1108,1110,1128,1130,1133,1136,1147,1154,1156,1210,1213,1217,1220,1460,1462,1466,1469,1506,1509,1513,1516,1552,1555,1559,1566,1602,1605,1609,1612,1648,1651,1653,1656,1685,1688,1690,1693,1766,1769,1771,1780],[91,1109,313],{"id":312},[388,1111,1112,1115,1121],{},[102,1113,1114],{},"A Node.js + Angular e-commerce project at a regional supermarket chain had reached pre-launch with no reliable deploy path.",[102,1116,1117,1118,325],{},"I joined as the second engineer on a two-person team, finished the platform, and shipped it as a ",[437,1119,1120],{},"PWA + mobile apps to a high-volume customer base",[102,1122,1123,1124,1127],{},"Along the way we built the ",[437,1125,1126],{},"Docker image registry and CI/CD pipeline"," that made continuous delivery possible — for this project and the ones that followed.",[91,1129,955],{"id":954},[71,1131,1132],{},"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.\"",[71,1134,1135],{},"The shape of the work is one any engineer who has joined a pre-launch greenfield will recognise:",[388,1137,1138,1141,1144],{},[102,1139,1140],{},"A codebase that ran in development but didn't yet have a repeatable build or deploy path.",[102,1142,1143],{},"An open question about the customer surface — PWA, native apps, or both.",[102,1145,1146],{},"Adjacent business needs (customer segmentation, exchange-rate ingestion) waiting on someone with the bandwidth to pick them up.",[71,1148,1149,1150],{},"The brief was simple in one sentence and hard in practice: ",[1151,1152,1153],"em",{},"finish it and ship it.",[91,1155,962],{"id":961},[979,1157,1158,1168],{},[982,1159,1160],{},[985,1161,1162,1165],{},[988,1163,1164],{},"Constraint",[988,1166,1167],{},"Reality",[998,1169,1170,1178,1186,1194,1202],{},[985,1171,1172,1175],{},[1003,1173,1174],{},"Team size",[1003,1176,1177],{},"2 engineers total, including me, embedded with the business",[985,1179,1180,1183],{},[1003,1181,1182],{},"Calendar",[1003,1184,1185],{},"Months, not quarters — the chain had already waited too long",[985,1187,1188,1191],{},[1003,1189,1190],{},"Stack inherited",[1003,1192,1193],{},"Node.js backend, Angular frontend, no deploy story",[985,1195,1196,1199],{},[1003,1197,1198],{},"Customer surface",[1003,1200,1201],{},"Needed to reach customers on phones, fast, without an app-store gauntlet",[985,1203,1204,1207],{},[1003,1205,1206],{},"Adjacent work",[1003,1208,1209],{},"Customer segmentation and exchange-rate ingestion were also on the table",[71,1211,1212],{},"The non-negotiable: ship something customers could use, then build the platform underneath that made the next thing easy.",[91,1214,1216],{"id":1215},"architecture-at-a-glance","Architecture at a glance",[71,1218,1219],{},"Based on the original delivery architecture (simplified from the internal diagram), this is the flow we shipped:",[130,1221,1225],{"className":1222,"code":1223,"language":1224,"meta":135,"style":135},"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  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","mermaid",[137,1226,1227,1232,1237,1242,1247,1252,1257,1263,1269,1274,1280,1286,1292,1298,1304,1309,1315,1320,1326,1332,1338,1343,1349,1354,1360,1366,1372,1377,1383,1389,1395,1400,1406,1412,1418,1424,1430,1436,1442,1448,1454],{"__ignoreMap":135},[140,1228,1229],{"class":142,"line":143},[140,1230,1231],{},"flowchart LR\n",[140,1233,1234],{"class":142,"line":149},[140,1235,1236],{},"  classDef ext fill:#e5e7eb,stroke:#6b7280,color:#282a30\n",[140,1238,1239],{"class":142,"line":155},[140,1240,1241],{},"  classDef svc fill:#dbeafe,stroke:#0f4c5c,color:#282a30\n",[140,1243,1244],{"class":142,"line":161},[140,1245,1246],{},"  classDef data fill:#d1fae5,stroke:#0f766e,color:#282a30\n",[140,1248,1249],{"class":142,"line":202},[140,1250,1251],{},"  classDef client fill:#fef3c7,stroke:#f59e0b,color:#282a30\n",[140,1253,1254],{"class":142,"line":736},[140,1255,1256],{"emptyLinePlaceholder":359},"\n",[140,1258,1260],{"class":142,"line":1259},7,[140,1261,1262],{},"  ERP[(ERP / iDempiere)]\n",[140,1264,1266],{"class":142,"line":1265},8,[140,1267,1268],{},"  M2[(Magento 2)]\n",[140,1270,1272],{"class":142,"line":1271},9,[140,1273,1256],{"emptyLinePlaceholder":359},[140,1275,1277],{"class":142,"line":1276},10,[140,1278,1279],{},"  subgraph Integration[Integration Layer]\n",[140,1281,1283],{"class":142,"line":1282},11,[140,1284,1285],{},"    INT[Integrator]\n",[140,1287,1289],{"class":142,"line":1288},12,[140,1290,1291],{},"    MS[Catalog Sync Microservice]\n",[140,1293,1295],{"class":142,"line":1294},13,[140,1296,1297],{},"  end\n",[140,1299,1301],{"class":142,"line":1300},14,[140,1302,1303],{},"  class INT,MS svc\n",[140,1305,1307],{"class":142,"line":1306},15,[140,1308,1256],{"emptyLinePlaceholder":359},[140,1310,1312],{"class":142,"line":1311},16,[140,1313,1314],{},"  DB[(MongoDB)]\n",[140,1316,1318],{"class":142,"line":1317},17,[140,1319,1256],{"emptyLinePlaceholder":359},[140,1321,1323],{"class":142,"line":1322},18,[140,1324,1325],{},"  subgraph APIs[Platform APIs]\n",[140,1327,1329],{"class":142,"line":1328},19,[140,1330,1331],{},"    PROC[Process API\\nCatalog, prices, tags, stock]\n",[140,1333,1335],{"class":142,"line":1334},20,[140,1336,1337],{},"    APP[App API\\nOrders, profile, addresses]\n",[140,1339,1341],{"class":142,"line":1340},21,[140,1342,1297],{},[140,1344,1346],{"class":142,"line":1345},22,[140,1347,1348],{},"  class PROC,APP svc\n",[140,1350,1352],{"class":142,"line":1351},23,[140,1353,1256],{"emptyLinePlaceholder":359},[140,1355,1357],{"class":142,"line":1356},24,[140,1358,1359],{},"  subgraph Clients[Customer Surface]\n",[140,1361,1363],{"class":142,"line":1362},25,[140,1364,1365],{},"    PWA[PWA Storefront]\n",[140,1367,1369],{"class":142,"line":1368},26,[140,1370,1371],{},"    MOB[Mobile App]\n",[140,1373,1375],{"class":142,"line":1374},27,[140,1376,1297],{},[140,1378,1380],{"class":142,"line":1379},28,[140,1381,1382],{},"  class PWA,MOB client\n",[140,1384,1386],{"class":142,"line":1385},29,[140,1387,1388],{},"  class ERP,M2 ext\n",[140,1390,1392],{"class":142,"line":1391},30,[140,1393,1394],{},"  class DB data\n",[140,1396,1398],{"class":142,"line":1397},31,[140,1399,1256],{"emptyLinePlaceholder":359},[140,1401,1403],{"class":142,"line":1402},32,[140,1404,1405],{},"  ERP --> INT\n",[140,1407,1409],{"class":142,"line":1408},33,[140,1410,1411],{},"  M2 --> INT\n",[140,1413,1415],{"class":142,"line":1414},34,[140,1416,1417],{},"  INT --> MS\n",[140,1419,1421],{"class":142,"line":1420},35,[140,1422,1423],{},"  MS --> DB\n",[140,1425,1427],{"class":142,"line":1426},36,[140,1428,1429],{},"  DB --> PROC\n",[140,1431,1433],{"class":142,"line":1432},37,[140,1434,1435],{},"  PROC --> PWA\n",[140,1437,1439],{"class":142,"line":1438},38,[140,1440,1441],{},"  PROC --> MOB\n",[140,1443,1445],{"class":142,"line":1444},39,[140,1446,1447],{},"  PWA --> APP\n",[140,1449,1451],{"class":142,"line":1450},40,[140,1452,1453],{},"  MOB --> APP\n",[140,1455,1457],{"class":142,"line":1456},41,[140,1458,1459],{},"  APP --> M2\n",[91,1461,977],{"id":976},[122,1463,1465],{"id":1464},"decision-1-ship-a-pwa-first-native-apps-second","Decision 1 — Ship a PWA first, native apps second",[71,1467,1468],{},"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.",[979,1470,1471,1482],{},[982,1472,1473],{},[985,1474,1475,1478,1480],{},[988,1476,1477],{},"Option",[988,1479,993],{},[988,1481,996],{},[998,1483,1484,1494],{},[985,1485,1486,1489,1492],{},[1003,1487,1488],{},"Build native iOS + Android apps from day one",[1003,1490,1491],{},"Months of platform-specific work; app-store review on every change",[1003,1493],{},[985,1495,1496,1499,1502],{},[1003,1497,1498],{},"Ship a PWA from the existing Angular code, wrap as mobile apps later",[1003,1500,1501],{},"Web-first surface available in days, not months; one codebase to maintain",[1003,1503,1504],{},[1022,1505],{},[71,1507,1508],{},"The mobile apps came after, on the foundation the PWA had already proven.",[122,1510,1512],{"id":1511},"decision-2-build-the-deploy-path-before-finishing-the-features","Decision 2 — Build the deploy path before finishing the features",[71,1514,1515],{},"Tempting as it was to chase the feature backlog, the bottleneck wasn't features — it was that nothing could be deployed reliably.",[979,1517,1518,1528],{},[982,1519,1520],{},[985,1521,1522,1524,1526],{},[988,1523,1477],{},[988,1525,993],{},[988,1527,996],{},[998,1529,1530,1540],{},[985,1531,1532,1535,1538],{},[1003,1533,1534],{},"Hand-deploy until launch, automate later",[1003,1536,1537],{},"Faster on day one; every deploy after that costs the same hour",[1003,1539],{},[985,1541,1542,1545,1548],{},[1003,1543,1544],{},"Stand up a private Docker registry + a thin CI/CD pipeline first",[1003,1546,1547],{},"Two weeks of platform work before any new feature shipped; every deploy after that was free",[1003,1549,1550],{},[1022,1551],{},[71,1553,1554],{},"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.",[122,1556,1558],{"id":1557},"decision-3-treat-customer-segmentation-as-a-small-useful-side-quest","Decision 3 — Treat customer segmentation as a small, useful side-quest",[71,1560,1561,1562,1565],{},"Marketing wanted to know which customers to talk to and when. I ran an ",[437,1563,1564],{},"RFM analysis"," (Recency, Frequency, Monetary) on the order data the new platform was already collecting.",[979,1567,1568,1578],{},[982,1569,1570],{},[985,1571,1572,1574,1576],{},[988,1573,1477],{},[988,1575,993],{},[988,1577,996],{},[998,1579,1580,1590],{},[985,1581,1582,1585,1588],{},[1003,1583,1584],{},"Wait until a dedicated data team exists",[1003,1586,1587],{},"Months of waiting on insight the business needed now",[1003,1589],{},[985,1591,1592,1595,1598],{},[1003,1593,1594],{},"Run RFM on the existing data, hand the segments to marketing",[1003,1596,1597],{},"Lightweight, immediately useful, and proved the new platform's data was worth something",[1003,1599,1600],{},[1022,1601],{},[71,1603,1604],{},"It wasn't a \"data platform.\" It was a one-engineer analysis that paid for itself the first week marketing used it.",[122,1606,1608],{"id":1607},"decision-4-automate-exchange-rate-ingestion-instead-of-typing-it-in","Decision 4 — Automate exchange-rate ingestion instead of typing it in",[71,1610,1611],{},"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.",[979,1613,1614,1624],{},[982,1615,1616],{},[985,1617,1618,1620,1622],{},[988,1619,1477],{},[988,1621,993],{},[988,1623,996],{},[998,1625,1626,1636],{},[985,1627,1628,1631,1634],{},[1003,1629,1630],{},"Keep doing it manually",[1003,1632,1633],{},"Free until it isn't — a single typo moves prices on a whole catalog",[1003,1635],{},[985,1637,1638,1641,1644],{},[1003,1639,1640],{},"Scrape the official rate on a schedule and push it into the platform",[1003,1642,1643],{},"A small ingestion job; removes a daily chore and a class of pricing bugs",[1003,1645,1646],{},[1022,1647],{},[71,1649,1650],{},"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.",[91,1652,1049],{"id":1048},[71,1654,1655],{},"By the end of the engagement:",[388,1657,1658,1664,1671,1678],{},[102,1659,1660,1661,325],{},"The platform shipped as a ",[437,1662,1663],{},"PWA + mobile apps used by a high-volume customer base",[102,1665,1666,1667,1670],{},"The company had a ",[437,1668,1669],{},"private Docker registry and a working CI/CD pipeline"," for Node.js and Angular services — the foundation for everything that came after.",[102,1672,1673,1674,1677],{},"Marketing had ",[437,1675,1676],{},"RFM-based customer segments"," they could act on, drawn from the platform's own data.",[102,1679,1680,1681,1684],{},"Pricing ran on an ",[437,1682,1683],{},"automated exchange-rate feed"," instead of a daily copy-paste.",[71,1686,1687],{},"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.",[91,1689,1076],{"id":1075},[71,1691,1692],{},"Same brief today, same team size, same constraints. The instincts wouldn't change; the targets would.",[388,1694,1695,1709,1723,1733,1747,1756],{},[102,1696,1697,1700,1701,1704,1705,1708],{},[437,1698,1699],{},"Frontend:"," ship the PWA the same way, but host it on ",[437,1702,1703],{},"Firebase Hosting"," or ",[437,1706,1707],{},"Cloudflare Pages"," with a CDN edge in front. Lighthouse perf as a CI gate, not an afterthought.",[102,1710,1711,1714,1715,1718,1719,1722],{},[437,1712,1713],{},"Backend:"," containerize the Node.js services for ",[437,1716,1717],{},"Cloud Run",". No private registry to maintain — ",[437,1720,1721],{},"Artifact Registry"," comes with the platform.",[102,1724,1725,1728,1729,1732],{},[437,1726,1727],{},"CI/CD:"," ",[437,1730,1731],{},"GitHub Actions → Cloud Run",", preview environment per PR. The pipeline I hand-built in 2022 is now ten lines of YAML.",[102,1734,1735,1738,1739,1742,1743,1746],{},[437,1736,1737],{},"RFM and segmentation:"," land the orders in ",[437,1740,1741],{},"BigQuery"," via a Cloud Run job, run the RFM query as a scheduled view, and surface the segments to marketing through ",[437,1744,1745],{},"Looker Studio"," instead of a CSV. Same analysis, no engineer in the loop after week one.",[102,1748,1749,1728,1752,1755],{},[437,1750,1751],{},"Exchange-rate ingestion:",[437,1753,1754],{},"Cloud Scheduler → Cloud Run job → Pub/Sub → BigQuery + the storefront cache",". The scraper still runs; everything around it stops being a cron on a single VM.",[102,1757,1758,1761,1762,1765],{},[437,1759,1760],{},"Mobile apps:"," Capacitor or PWABuilder wrapping the same PWA, signed and shipped through ",[437,1763,1764],{},"Firebase App Distribution"," for staged rollouts.",[71,1767,1768],{},"The shape of the work stays the same — deploy path first, customer surface next, the smaller automations after that. GCP just removes a lot of the platform plumbing that used to be the job.",[699,1770],{},[71,1772,1773],{},[1151,1774,1775,1776,325],{},"If you're working through something similar and want a second pair of eyes, I'm happy to talk it through — ",[692,1777,1779],{"href":1778},"/contact","reach out here",[339,1781,1782],{},"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":135,"searchDepth":149,"depth":149,"links":1784},[1785,1786,1787,1788,1789,1795,1796],{"id":312,"depth":149,"text":313},{"id":954,"depth":149,"text":955},{"id":961,"depth":149,"text":962},{"id":1215,"depth":149,"text":1216},{"id":976,"depth":149,"text":977,"children":1790},[1791,1792,1793,1794],{"id":1464,"depth":155,"text":1465},{"id":1511,"depth":155,"text":1512},{"id":1557,"depth":155,"text":1558},{"id":1607,"depth":155,"text":1608},{"id":1048,"depth":149,"text":1049},{"id":1075,"depth":149,"text":1076},"case-study"," 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":31,"description":1800},[1807,1808,1809,1810,1811],"Node.js","Angular","PWA","Docker","CI/CD",[1813,1814,1815],"e-commerce","platform-recovery","devops","rescue",2022,"Bh1agRhcM-FwItD5ic5ty0G7jKWWU3IO8HnDkEGfByk",{"id":1820,"title":48,"author":1821,"body":1823,"category":353,"client":354,"date":1957,"description":1958,"extension":357,"featured":34,"hidden":34,"image":1959,"meta":1960,"minRead":161,"navigation":359,"ogImage":354,"outcome_headline":354,"path":49,"repoUrl":354,"role":354,"seo":1961,"stack":354,"stem":50,"tags":1962,"team_size":354,"type":354,"year":354,"__hash__":1964},"blog/blog/intern-movie-torrent.md",{"name":62,"username":63,"to":64,"avatar":1822},{"src":66,"alt":62},{"type":68,"value":1824,"toc":1953},[1825,1831,1834,1837,1844,1848,1851,1871,1875,1884,1888,1895,1923,1932,1936,1946],[71,1826,1827],{},[83,1828],{"alt":1829,"src":1830},"Pie chart of types of torrents","/blog/intern-movie-torrent/venezuela_torrent.png",[1832,1833,377],"h1",{"id":376},[71,1835,1836],{},"Venezuela has a bandwidth problem, and plenty of people will stretch whatever connection is available—especially the internet at work.",[71,1838,1839,1840,1843],{},"What happens when you let tech-savvy people stay online for eight hours a day? I call it ",[437,1841,1842],{},"The Intern is downloading movies"," problem.",[1832,1845,1847],{"id":1846},"solution","Solution",[71,1849,1850],{},"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:",[99,1852,1853,1861,1864],{},[102,1854,1855,1856,325],{},"Visit ",[692,1857,1860],{"href":1858,"rel":1859},"https://iknowwhatyoudownload.com/en/peer/",[696],"iknowwhatyoudownload.com",[102,1862,1863],{},"On the homepage, you’ll see your current external IP address.",[102,1865,1866,1867],{},"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",[83,1868],{"alt":1869,"src":1870},"Home page of the tool","/blog/intern-movie-torrent/venezuela_torrent2.png",[91,1872,1874],{"id":1873},"for-tracking-other-people","For tracking other people",[71,1876,1877,1878,1883],{},"You can ",[692,1879,1882],{"href":1880,"rel":1881},"https://iknowwhatyoudownload.com/en/link/",[696],"create a link"," and send it; the tracker logs activity as soon as someone clicks.",[91,1885,1887],{"id":1886},"for-tracking-peers","For tracking peers",[71,1889,1890,1891,1894],{},"If you have the ",[137,1892,1893],{},".torrent"," file you can extract peers and seeds from your torrent client (e.g., qBittorrent).",[99,1896,1897,1900,1914],{},[102,1898,1899],{},"Copy the selected peer or host (or everything if you have time).",[102,1901,1902,1903,1704,1908,1913],{},"Convert the host address using an IP lookup tool like ",[692,1904,1907],{"href":1905,"rel":1906},"https://www.ip-tracker.org/",[696],"ip-tracker.org",[692,1909,1912],{"href":1910,"rel":1911},"https://whois.domaintools.com/",[696],"whois.domaintools.com"," if it’s not listed.",[102,1915,1916,1917,1922],{},"Use ",[692,1918,1921],{"href":1919,"rel":1920},"https://iphub.info/",[696],"iphub.info"," to determine whether the IP is a proxy or a residential address.",[71,1924,1925,1926,1931],{},"Note that VPNs and proxies may give noisy results, as explained in the ",[692,1927,1930],{"href":1928,"rel":1929},"https://iknowwhatyoudownload.com/en/contacts/",[696],"FAQ",". Still, it’s rare to see premium torrent VPNs inside organizations in Venezuela because perimeter firewalls like Fortinet often block that traffic.",[1832,1933,1935],{"id":1934},"references","References",[71,1937,1938,1939,711,1943],{},"Photo by ",[692,1940,1942],{"href":1941},"https://unsplash.com/@jsshotz?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash","Jorge Salvador",[692,1944,715],{"href":1945},"https://unsplash.com/photos/black-and-white-satellite-dish-wjMMVxy8C0g?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[71,1947,1948],{},[692,1949,1952],{"href":1950,"rel":1951},"https://torrentfreak.com/i-know-what-you-download-overwhelmed-by-bogus-dmca-notices-221023/",[696],"Torrent Freak",{"title":135,"searchDepth":149,"depth":149,"links":1954},[1955,1956],{"id":1873,"depth":149,"text":1874},{"id":1886,"depth":149,"text":1887},"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":48,"description":1958},[1963],"venezuela","MMJLNTnWg5XgfIoE4qkpsnIuxC794I1Uw16abClpxAk",{"id":1966,"title":44,"author":1967,"body":1969,"category":731,"client":354,"date":2140,"description":2141,"extension":357,"featured":34,"hidden":34,"image":2142,"meta":2143,"minRead":161,"navigation":359,"ogImage":354,"outcome_headline":2144,"path":45,"repoUrl":1987,"role":738,"seo":2145,"stack":2146,"stem":46,"tags":2148,"team_size":143,"type":731,"year":2149,"__hash__":2150},"blog/blog/from-nuxt-to-hugo.md",{"name":62,"username":63,"to":64,"avatar":1968},{"src":66,"alt":62},{"type":68,"value":1970,"toc":2133},[1971,1973,1975,1978,1980,1983,1990,1993,1997,2001,2038,2042,2074,2078,2110,2122,2125,2127,2130],[1832,1972,377],{"id":376},[91,1974,313],{"id":312},[71,1976,1977],{},"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.",[91,1979,955],{"id":954},[71,1981,1982],{},"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.",[71,1984,1985],{},[692,1986,1989],{"href":1987,"rel":1988},"https://github.com/ivanovertime/alvarezivan.net",[696],"Check the repo",[71,1991,1992],{},"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.",[1832,1994,1996],{"id":1995},"what-changed","What changed",[91,1998,2000],{"id":1999},"decisions-made","Decisions Made",[979,2002,2003,2013],{},[982,2004,2005],{},[985,2006,2007,2009,2011],{},[988,2008,990],{},[988,2010,993],{},[988,2012,996],{},[998,2014,2015,2026],{},[985,2016,2017,2020,2023],{},[1003,2018,2019],{},"Use Hugo for this site",[1003,2021,2022],{},"Fast builds, simple deployment; limited component reuse and markdown composition",[1003,2024,2025],{},"(Temporary)",[985,2027,2028,2031,2034],{},[1003,2029,2030],{},"Return to Nuxt 4 + Nuxt Content",[1003,2032,2033],{},"More flexible, better for rich components; slightly heavier build; component-first instead of static-first",[1003,2035,2036],{},[1022,2037],{},[91,2039,2041],{"id":2040},"what-changed-1","What Changed",[99,2043,2044,2054,2064],{},[102,2045,2046,2049],{},[437,2047,2048],{},"Content model",[388,2050,2051],{},[102,2052,2053],{},"Hugo proved fast and lean for static content, but the authoring workflow and UI composition were more constrained than I wanted.",[102,2055,2056,2059],{},[437,2057,2058],{},"Rendering strategy",[388,2060,2061],{},[102,2062,2063],{},"Moving back to Nuxt restored dynamic layouts, component reuse, and flexible data sourcing without sacrificing static performance.",[102,2065,2066,2069],{},[437,2067,2068],{},"Deploy & hosting",[388,2070,2071],{},[102,2072,2073],{},"Cloudflare Pages made preview deployments frictionless, with simple caching rules and edge delivery that fit a static‑first site.",[1832,2075,2077],{"id":2076},"tech-used","Tech used",[388,2079,2080,2086,2092,2098,2104],{},[102,2081,2082,2085],{},[437,2083,2084],{},"Frameworks:"," Hugo, Nuxt (Vue)",[102,2087,2088,2091],{},[437,2089,2090],{},"Content:"," Markdown with front matter",[102,2093,2094,2097],{},[437,2095,2096],{},"Styling:"," Tailwind CSS",[102,2099,2100,2103],{},[437,2101,2102],{},"Hosting:"," Cloudflare Pages",[102,2105,2106,2109],{},[437,2107,2108],{},"Tooling:"," pnpm, GitHub Actions",[75,2111,2112],{},[71,2113,2114,2115,2118,2119,2121],{},"It's not DNS ",[2116,2117],"br",{},"\nIt cannot be DNS ",[2116,2120],{},"\nIt was DNS",[71,2123,2124],{},"Good luck with your migration! If you hit a snag, it's probably DNS—check it anyway.",[91,2126,1049],{"id":1048},[71,2128,2129],{},"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.",[71,2131,2132],{},"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":135,"searchDepth":149,"depth":149,"links":2134},[2135,2136,2137,2138,2139],{"id":312,"depth":149,"text":313},{"id":954,"depth":149,"text":955},{"id":1999,"depth":149,"text":2000},{"id":2040,"depth":149,"text":2041},{"id":1048,"depth":149,"text":1049},"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":44,"description":2141},[2147,1096,1097,1098,1707],"Hugo",[1100,1814],2024,"ri07wzD2yxtckMgfwuu-zXFFOyRllpn_7YwYxq-kaok",{"id":2152,"title":10,"author":2153,"body":2155,"category":731,"client":354,"date":2429,"description":2430,"extension":357,"featured":34,"hidden":34,"image":2431,"meta":2432,"minRead":1259,"navigation":359,"ogImage":354,"outcome_headline":2433,"path":11,"repoUrl":2334,"role":738,"seo":2434,"stack":2435,"stem":12,"tags":2440,"team_size":143,"type":731,"year":2149,"__hash__":2442},"blog/blog/bcv-scraper.md",{"name":62,"username":63,"to":64,"avatar":2154},{"src":66,"alt":62},{"type":68,"value":2156,"toc":2415},[2157,2159,2161,2164,2166,2180,2194,2203,2217,2220,2231,2240,2242,2246,2249,2285,2288,2292,2296,2305,2309,2312,2315,2329,2337,2340,2342,2344,2347,2350,2361],[91,2158,377],{"id":376},[91,2160,313],{"id":312},[71,2162,2163],{},"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.",[91,2165,955],{"id":954},[71,2167,2168,2169,2179],{},"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 ",[2170,2171,2172],"sup",{},[692,2173,2178],{"href":2174,"ariaDescribedBy":2175,"dataFootnoteRef":135,"id":2177},"#user-content-fn-1",[2176],"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:",[71,2181,2182,2185,2186,325],{},[437,2183,2184],{},"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 ",[2170,2187,2188],{},[692,2189,2193],{"href":2190,"ariaDescribedBy":2191,"dataFootnoteRef":135,"id":2192},"#user-content-fn-2",[2176],"user-content-fnref-2","2",[71,2195,2196,2197,2202],{},"Its tech stack is an Odoo instance. There's no mobile app and billing happens directly in-store. A ",[692,2198,2201],{"href":2199,"rel":2200},"https://apps.odoo.com/apps/modules/browse?search=BCV",[696],"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.",[71,2204,2205,2208,2209,325],{},[437,2206,2207],{},"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 ",[2170,2210,2211],{},[692,2212,2216],{"href":2213,"ariaDescribedBy":2214,"dataFootnoteRef":135,"id":2215},"#user-content-fn-3",[2176],"user-content-fnref-3","3",[71,2218,2219],{},"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:",[99,2221,2222,2225,2228],{},[102,2223,2224],{},"A web scraper: a program that extracts data from a website by parsing its HTML code.",[102,2226,2227],{},"RESTful APIs with minimal code and high performance.",[102,2229,2230],{},"Database storage for historical data (for returns, refunds, and the like).",[71,2232,2233,2234,2239],{},"If you use a service like ",[692,2235,2238],{"href":2236,"rel":2237},"https://www.exchangerate-api.com/",[696],"Exchange Rate API"," you can get the daily exchange rate, but you could get rate-limited quickly.",[91,2241,1847],{"id":1846},[91,2243,2245],{"id":2244},"decision-api-service-or-scheduled-job","Decision: API service or scheduled job?",[71,2247,2248],{},"You have two paths here:",[979,2250,2251,2261],{},[982,2252,2253],{},[985,2254,2255,2257,2259],{},[988,2256,1477],{},[988,2258,993],{},[988,2260,996],{},[998,2262,2263,2273],{},[985,2264,2265,2268,2271],{},[1003,2266,2267],{},"Third-party exchange rate API (e.g., exchangerate-api.com)",[1003,2269,2270],{},"You pay per request or per month; rate-limited on free tier; adds a dependency",[1003,2272],{},[985,2274,2275,2278,2281],{},[1003,2276,2277],{},"Build your own scraper + microservice",[1003,2279,2280],{},"Runs on your infrastructure; handles bursts free; full control of the rate feed",[1003,2282,2283],{},[1022,2284],{},[71,2286,2287],{},"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.",[91,2289,2291],{"id":2290},"implementation","Implementation",[122,2293,2295],{"id":2294},"if-you-have-hardware","If you have hardware",[71,2297,2298,2299,2304],{},"Creating a web scraper and using it with ",[692,2300,2303],{"href":2301,"rel":2302},"https://www.crawlab.cn/en",[696],"crawlab"," as storage in MongoDB is a great solution.",[122,2306,2308],{"id":2307},"if-you-dont","If you don't",[71,2310,2311],{},"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.",[71,2313,2314],{},"By using a web scraper built in Python with FastAPI, you can:",[388,2316,2317,2320,2323,2326],{},[102,2318,2319],{},"Automate the process of fetching the latest exchange rates from the Banco Central de Venezuela website.",[102,2321,2322],{},"Convert the scraped data into JSON format, which can be stored, transmitted, and processed by various applications and tools.",[102,2324,2325],{},"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.",[102,2327,2328],{},"Apply data analysis, visualization, or machine learning techniques to the scraped data, using the rich set of libraries and packages available in Python.",[71,2330,2331,2332,325],{},"I published a starter implementation in this ",[692,2333,2336],{"href":2334,"rel":2335},"https://github.com/ivanovertime/bcv_scraper",[696],"repo",[71,2338,2339],{},"Let me know if you have any questions.",[1832,2341,1935],{"id":1934},[91,2343,1049],{"id":1048},[71,2345,2346],{},"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.",[91,2348,1935],{"id":2349},"references-1",[71,2351,1938,2352,711,2357],{},[692,2353,2356],{"href":2354,"rel":2355},"https://unsplash.com/@frederickjmedina?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[696],"Frederick Medina",[692,2358,715],{"href":2359,"rel":2360},"https://unsplash.com/photos/person-holding-two-brown-doughnuts-LxyT2CgQSj8?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[696],[2362,2363,2366,2371],"section",{"className":2364,"dataFootnotes":135},[2365],"footnotes",[91,2367,2370],{"className":2368,"id":2176},[2369],"sr-only","Footnotes",[99,2372,2373,2389,2402],{},[102,2374,2376,2377,2381,2382],{"id":2375},"user-content-fn-1","E-commerce in Venezuela. ",[692,2378,2379],{"href":2379,"rel":2380},"https://www.lloydsbanktrade.com/en/market-potential/venezuela/ecommerce",[696],". ",[692,2383,2388],{"href":2384,"ariaLabel":2385,"className":2386,"dataFootnoteBackref":135},"#user-content-fnref-1","Back to reference 1",[2387],"data-footnote-backref","↩",[102,2390,2392,2393,2381,2397],{"id":2391},"user-content-fn-2","Librería La Alegría. ",[692,2394,2395],{"href":2395,"rel":2396},"https://www.librerialaalegria.com.ve/",[696],[692,2398,2388],{"href":2399,"ariaLabel":2400,"className":2401,"dataFootnoteBackref":135},"#user-content-fnref-2","Back to reference 2",[2387],[102,2403,2405,2406,2381,2410],{"id":2404},"user-content-fn-3","Tu Zona Market. ",[692,2407,2408],{"href":2408,"rel":2409},"https://tuzonamarket.com/",[696],[692,2411,2388],{"href":2412,"ariaLabel":2413,"className":2414,"dataFootnoteBackref":135},"#user-content-fnref-3","Back to reference 3",[2387],{"title":135,"searchDepth":149,"depth":149,"links":2416},[2417,2418,2419,2420,2421,2422,2426,2427,2428],{"id":376,"depth":149,"text":377},{"id":312,"depth":149,"text":313},{"id":954,"depth":149,"text":955},{"id":1846,"depth":149,"text":1847},{"id":2244,"depth":149,"text":2245},{"id":2290,"depth":149,"text":2291,"children":2423},[2424,2425],{"id":2294,"depth":155,"text":2295},{"id":2307,"depth":155,"text":2308},{"id":1048,"depth":149,"text":1049},{"id":2349,"depth":149,"text":1935},{"id":2176,"depth":149,"text":2370},"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":2430},[2436,2437,2438,2439],"Python","FastAPI","Web Scraping","REST API",[1963,2441],"integrations","7hCXnCEL7ZE6dGeOHKXFGKLJSWGtP64pKVtxG-Cum4Y",{"id":2444,"title":52,"author":2445,"body":2447,"category":353,"client":354,"date":2651,"description":2652,"extension":357,"featured":34,"hidden":34,"image":2653,"meta":2654,"minRead":202,"navigation":359,"ogImage":354,"outcome_headline":354,"path":53,"repoUrl":354,"role":354,"seo":2655,"stack":354,"stem":54,"tags":2656,"team_size":354,"type":354,"year":354,"__hash__":2658},"blog/blog/japanese-input.md",{"name":62,"username":63,"to":64,"avatar":2446},{"src":66,"alt":62},{"type":68,"value":2448,"toc":2648},[2449,2451,2454,2469,2471,2483,2494,2507,2555,2558,2565,2574,2585,2596,2606,2608,2610,2617,2645],[1832,2450,377],{"id":376},[71,2452,2453],{},"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.",[71,2455,2456,2457,2460,2461,2464,2465,2468],{},"Here are the steps to install ",[437,2458,2459],{},"fcitx5"," and ",[437,2462,2463],{},"fcitx5-mozc"," input method for Japanese in ",[437,2466,2467],{},"openSUSE Tumbleweed",":",[1832,2470,1847],{"id":1846},[71,2472,2473,2474,2460,2476,2478,2468],{},"Open the terminal and enter the following command to install ",[437,2475,2459],{},[437,2477,2463],{},[2170,2479,2480],{},[692,2481,2178],{"href":2174,"ariaDescribedBy":2482,"dataFootnoteRef":135,"id":2177},[2176],[130,2484,2488],{"className":2485,"code":2486,"language":2487,"meta":135,"style":135},"language-shell shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","sudo zypper install fcitx5-mozc\n","shell",[137,2489,2490],{"__ignoreMap":135},[140,2491,2492],{"class":142,"line":143},[140,2493,2486],{},[71,2495,2496,2497,2502,2503,2506],{},"Next, if you aren't using a desktop environment ",[2170,2498,2499],{},[692,2500,2193],{"href":2190,"ariaDescribedBy":2501,"dataFootnoteRef":135,"id":2192},[2176]," you may need to configure the input method. To do this, create or open ",[137,2504,2505],{},"~/.profile"," and add the following lines:",[130,2508,2510],{"className":261,"code":2509,"language":263,"meta":135,"style":135},"export GTK_IM_MODULE=fcitx\nexport QT_IM_MODULE=fcitx\nexport XMODIFIERS=@im=fcitx\n",[137,2511,2512,2528,2539],{"__ignoreMap":135},[140,2513,2514,2518,2522,2525],{"class":142,"line":143},[140,2515,2517],{"class":2516},"spNyl","export",[140,2519,2521],{"class":2520},"sTEyZ"," GTK_IM_MODULE",[140,2523,2524],{"class":646},"=",[140,2526,2527],{"class":2520},"fcitx\n",[140,2529,2530,2532,2535,2537],{"class":142,"line":149},[140,2531,2517],{"class":2516},[140,2533,2534],{"class":2520}," QT_IM_MODULE",[140,2536,2524],{"class":646},[140,2538,2527],{"class":2520},[140,2540,2541,2543,2546,2548,2551,2553],{"class":142,"line":155},[140,2542,2517],{"class":2516},[140,2544,2545],{"class":2520}," XMODIFIERS",[140,2547,2524],{"class":646},[140,2549,2550],{"class":2520},"@im",[140,2552,2524],{"class":646},[140,2554,2527],{"class":2520},[71,2556,2557],{},"Save the file and restart your system.",[71,2559,2560,2561,2564],{},"Once you've restarted, open the ",[437,2562,2563],{},"fcitx5 configuration tool"," by running:",[130,2566,2568],{"className":261,"code":2567,"language":263,"meta":135,"style":135},"fcitx5-configtool\n",[137,2569,2570],{"__ignoreMap":135},[140,2571,2572],{"class":142,"line":143},[140,2573,2567],{"class":270},[71,2575,2576,2577,2580,2581,2584],{},"In the configuration tool, click the ",[437,2578,2579],{},"Input Method"," tab and select the ",[437,2582,2583],{},"+"," button to add a new input method.",[71,2586,2587,2588,2591,2592,2595],{},"Select ",[437,2589,2590],{},"Mozc"," from the list of input methods and click on the ",[437,2593,2594],{},"Add"," button.",[71,2597,2598,2599,2601,2602,2605],{},"You can now switch to the ",[437,2600,2590],{}," input method by pressing the ",[437,2603,2604],{},"Ctrl + Space"," keys.",[71,2607,2339],{},[1832,2609,1935],{"id":1934},[71,2611,1938,2612,711,2614],{},[692,2613,2356],{"href":2354},[692,2615,715],{"href":2616},"https://unsplash.com/photos/person-holding-brown-bread-with-yellow-sliced-fruit-6SWHWcLyD-8?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[2362,2618,2620,2623],{"className":2619,"dataFootnotes":135},[2365],[91,2621,2370],{"className":2622,"id":2176},[2369],[99,2624,2625,2635],{},[102,2626,2627,2628,1728,2632],{"id":2375},"Japanese input in plasma 5.8 kde ",[692,2629,2630],{"href":2630,"rel":2631},"https://forums.opensuse.org/t/japanese-input-in-plasma-5-8-kde/125211",[696],[692,2633,2388],{"href":2384,"ariaLabel":2385,"className":2634,"dataFootnoteBackref":135},[2387],[102,2636,2637,2638,2381,2642],{"id":2391},"Localization/Japanese - ArchWiki. ",[692,2639,2640],{"href":2640,"rel":2641},"https://wiki.archlinux.org/title/Localization/Japanese",[696],[692,2643,2388],{"href":2399,"ariaLabel":2400,"className":2644,"dataFootnoteBackref":135},[2387],[339,2646,2647],{},"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}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}",{"title":135,"searchDepth":149,"depth":149,"links":2649},[2650],{"id":2176,"depth":149,"text":2370},"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":52,"description":2652},[2657],"linux","6qJc2j8f2e9mMtdcDMW6WzBb6HNngdpNUQAPOI4qFW8",1783694873246]