GMT+8 SG --:-- 0000 X 0000 Y /
← 全部文章← All writing

VOLO engineering diary

Engineering Diary, Day 5: The FocusJet Widget Saga & Resurrecting 70 Dead Gallery Images

Originally published on flyvolo.ai as part of the VOLO Engineering Diary (2026-02-16), documenting the build of the VOLO private-aviation platform between February and May 2026. When VOLO changed direction in September 2026 the 25 entries were moved here as an archive. The text is unchanged; the products, pages and links it mentions no longer exist. Original byline: VOLO CTO.

Two Fires, One Day

Today was one of those days where you think you're shipping a quick fix and end up redesigning an entire architecture. We had two problems: 70 broken gallery images across our 100 destination pages, and a FocusJet minimized widget that kept trapping users on the wrong page. Both got solved. Both taught us something.

Part I: Resurrecting 70 Dead Unsplash URLs

We discovered that 70 out of 340 unique Unsplash gallery URLs across our destination content were returning 404 errors. These are the hero images visitors see when browsing destinations like Bali, Santorini, or the Maldives. Broken images on a luxury platform is like a five-star hotel with missing paintings — it destroys trust instantly.

The Problem

Unsplash periodically removes photos (photographer takedowns, policy violations, account deletions). Our destination data had hardcoded photo IDs that pointed to deleted images. Manual replacement of 70 URLs across a 3,000-line TypeScript file was not an option.

The Programmatic Fix

We wrote a three-stage Node.js pipeline:

  • Stage 1: Detection. Extracted all 340 unique Unsplash URLs from destinations.ts, ran HEAD requests in parallel, and identified 70 that returned non-200 status codes.
  • Stage 2: Search. For each broken URL, we queried the Unsplash search API (/napi/search/photos) with the destination name as the search term. The script extracted the first high-quality result's photo ID from the urls.raw field. First pass found 57/70 replacements; a retry with alternative search queries (e.g., "Musandam fjords" instead of "Musandam") found the remaining 13.
  • Stage 3: Replacement. A final script performed string replacements in destinations.ts, swapping old photo IDs for verified new ones.

Verification: all 340 URLs now return HTTP 200. Zero manual edits. The entire pipeline took less time to write than manually replacing 10 images would have.

Lesson: When your content has external dependencies (CDN images, API endpoints, third-party embeds), you need automated health checks. We are building a CI step that runs weekly URL verification on all destination gallery images.

Part II: The FocusJet Widget — Four Iterations to Get It Right

FocusJet is our aviation-themed Pomodoro timer. Users select an aircraft, set a route, and "fly" a focus session with a 3D globe visualization. The feature was working perfectly in full-screen mode. The problem was: what happens when the user minimizes it?

Iteration 1: Bottom-Right Card

First attempt: a floating card in the bottom-right corner, similar to a music player mini-widget. Problem: it overlapped with our AI Concierge chat widget, which lives in the same corner. Two floating widgets fighting for the same 200x200 pixel zone is a UX disaster.

Iteration 2: Full-Width Top Bar

Moved the widget to a full-width bar at the top of the viewport. Problem: it competed with our fixed navbar (position: fixed; top: 0; z-index: 9999). Two fixed bars at the top created a visual collision. Screenshot from CEO confirmed: it looked broken.

Iteration 3: Floating Capsule Below Navbar

Compact capsule positioned at top: 80px (just below the 80px navbar). Visually clean, no overlap. But then came the real feedback: "Users still cannot click anything. They are trapped on the FocusJet page."

This was the fundamental insight we had been missing. All three iterations shared the same architectural flaw: the widget was rendered inside the FocusJet page component. When the user "minimized," they were still on /tools/focus-jet — the page's <main> element still occupied the viewport, the router still pointed to FocusJet, and the user could not navigate anywhere.

Iteration 4: Global Context + Layout-Level Widget

The correct architecture required three changes:

  • Global state via React Context. We extracted the entire FocusJet reducer state into a FocusJetProvider that wraps the layout. This means the timer, pause state, aircraft selection, and route all live above any individual page.
  • Layout-level widget rendering. The FocusJetWidget component now lives in ModeAwareLayout.tsx alongside the ChatWidget — not inside the FocusJet page. It renders on every page in the app when a session is active.
  • Page navigation on minimize. When the user clicks "minimize," we call router.push("/") to navigate them to the homepage. The FocusJet page returns null when in widget mode. The user is free to browse destinations, explore the fleet, read the blog — all while their focus timer ticks in a slim bar below the navbar.

localStorage Persistence

The CEO's next requirement: "Even if the user refreshes the page, the timer must continue." This meant the global context was not enough — we needed persistence.

Our solution: on every state change (and on beforeunload), we serialize the FocusJet state to localStorage with a _savedAt timestamp. On page load, the provider checks for persisted state. If found, it calculates the real seconds elapsed while the page was closed:

  • If the timer was running (not paused), we add (Date.now() - _savedAt) / 1000 to elapsedSeconds
  • If the elapsed time exceeds the total duration, we transition directly to the "landing" phase
  • If the timer was paused, we restore the exact same state (no time added)

This means a user can start a 45-minute focus session, close their laptop, open it 30 minutes later, and see the timer showing 15 minutes remaining. The session survives browser tabs, page refreshes, and full browser restarts.

The Final Widget Design

We matched a reference screenshot from the CEO: a slim light bar with a green active dot, the aircraft name in a pill badge, the ICAO route code, a monospace countdown timer, and a pause/resume button. It sits at z-index: 9998 (one below the navbar's 9999), uses backdrop-filter: blur(12px) for a frosted glass effect, and occupies just 36 pixels of vertical space. Clicking anywhere on the bar navigates back to the full FocusJet page and expands the session.

The key architectural lesson: if your minimized component needs to be visible across every page of your app, it cannot live inside any single page. It must live at the layout level with global state. This is the same pattern Spotify uses for their "now playing" bar — and for good reason.

Technical Insights

useReducer Initial State Gotcha

React's useReducer only reads its initial state argument on the first render. Our loadPersistedState() call at the top of the provider body runs on every render but only matters once. This is harmless but wasteful. The correct optimization is to use the lazy initializer form: useReducer(reducer, undefined, loadPersistedState). Filed as a follow-up task.

z-index Hierarchy for Fixed Elements

With three fixed-position elements (navbar, FocusJet widget, chat widget), we needed a clear stacking order:

  • z-9999: Navbar (always on top)
  • z-9999: Chat widget (same level as navbar, different corner)
  • z-9998: FocusJet widget bar (just below, full width under navbar)

Elapsed Time Calculation Precision

Using Math.floor() for the seconds-away calculation means we slightly undercount elapsed time (by up to 999ms). This is intentional — for a focus timer, it is better to give the user slightly more time than to cut them short. Over-counting could cause the timer to land prematurely, which feels like a bug.

What Shipped Today

ChangeFilesImpact
Gallery URL resurrectiondestinations.ts (80 replacements)70 broken images fixed across 100 destination pages
FocusJet global widget5 files (2 new, 3 modified)Timer persists across navigation and refresh
Chat widget cleanupChatWidget.tsxRemoved proactive greeting bubble

Tomorrow

The FocusJet widget is functional but there are polish items: haptic feedback on mobile pause/resume, a subtle "ding" sound when the timer completes, and accessibility improvements (screen reader announcements for timer state changes). The gallery health check CI step is also on the backlog.

Sometimes the simplest-looking feature — "minimize a timer" — requires the deepest architectural changes. The four iterations were not failures; each one revealed a constraint we did not know existed. That is the nature of building products with real users giving real-time feedback.