JimmyVanVeen.com
By the third morning of our Disney World trip, my wife had stopped asking me what was next. She'd open her phone, tap a navy and gold icon on her home screen, and pull up the day. Same icon I had on mine. Same plan she'd helped shape. The Disney app handled the live state of the park: wait times, Lightning Lane bookings, mobile orders. Ours handled the plan we'd made at home and the references we'd want to pull up in real time once we got there. Different jobs, different requirements.
The icon was a custom progressive web app I'd built in the week before we flew out. It held our flights, the resorts on our itinerary, our park strategies for the four parks we'd be visiting, the restaurant menus, the four park map PDFs, and a packing list for all three of us. It ran on a server in my basement and reached our phones over Tailscale for installs and updates; in the parks, every page came straight from the device cache, which meant it worked completely offline. There was no public URL to leak, no login to forget, no certificate to renew. It was the first thing my wife reached for every morning, for eight days. She came back to it through the day too — pulling up the dinner reservation in the afternoon, the next day's card before bed.
The whole stack is plain HTML, a single small app.js, a service worker, and an nginx:alpine container behind a Tailscale sidecar. The build step is cp -r. The deploy is a Docker Compose up. No framework, no bundler, no backend, no public ingress, no auth layer. Every choice was made by working backward from what my wife would do with the thing in line for Slinky Dog at noon.
What it held
The thing it held that mattered most was the day. Each of the eight days had its own card on the index, with the current one haloed in gold and labeled TODAY. Tap a card and you got that day's plan.
Take Tuesday, the day we moved from the Polynesian to the Beach Club. The Tuesday card opened to a plan that read like a quietly ambitious itinerary. The morning had hard times because morning logistics demanded them: 7 a.m. breakfast and luggage to bell services, on the monorail by 8:05, tapped into Epcot by 8:20 to rope-drop Guardians of the Galaxy. After that, no clocks. Just an ordered sequence of attractions: Test Track, Soarin' Around the World, Living With the Land, Remy's Ratatouille Adventure. The afternoon was deliberately freelance, with a few candidates listed (Frozen Ever After, Spaceship Earth, World Showcase, the hotel) and a 6:30 p.m. anchor at Teppan Edo in the Japan Pavilion, linked through to the restaurant page. After dinner: walk out the International Gateway and into the Beach Club, our new resort for the night. Beneath the itinerary sat a "Resources for today" block with chips for the restaurant, the Epcot park page, the Epcot map PDF, and the Beach Club resort page. Beneath that, a list of every quick service option in Epcot. Beneath that, links to the day before and the day after.
Most of the days were structured this way: a tight rope-drop sequence with a clear rationale, a freelance afternoon with options not commands, a dinner anchor, and a resource block with everything you might want to tap into without leaving the day. Hollywood Studios was the exception, with more "firm" slots because I'd booked Lightning Lane Multi Pass return windows weeks in advance and the times were what they were.
Sitting alongside the day pages was a page for each of the four properties we stayed at: the Hyatt Regency Grand Cypress on the bookend nights, with the Polynesian, Beach Club, and BoardWalk in the middle of the week. Each resort page had check-in and check-out windows, transfer notes, and the small things you forget by Wednesday. There were pages for each of the four parks we visited, with our intended starting sequences and the rationale. Restaurant menus with prices in a small gold pill in the right column. A packing page broken up by person and by category. And the four park map PDFs, cached up front at install so they opened offline.
The plan was there to be deviated from
The plan itself was deliberately loose. I only planned the start of each park day in advance. After the first two or three rides, the page told you to make it up. The homework you do at home is most useful as a baseline for real-time decisions, not as a script — and on Tuesday at Epcot, the script broke immediately.
We rope-dropped Guardians of the Galaxy, got off in twenty-two minutes, walked to the bathroom. By the time we came out, Test Track was "Temporarily Closed." So we shifted to Soarin', then Living With the Land (the slow boat ride next door), checking the Disney app for Test Track every fifteen minutes. It never reopened in any meaningful window. We got our Remy's Ratatouille time in, walked the International Gateway to the Beach Club so my daughter could swim while we waited for the room to be ready (she loved it), and reorganized the rest of the day around what was actually possible. We came back to Epcot in the evening for a Lightning Lane on Guardians, did Spaceship Earth, ate at Teppan Edo at 6:30. After dinner my wife went back to the room and my daughter and I stood in line for Test Track for an hour to finally close out the day.
Almost none of that was on the original card. All of it was easy to do because the original card existed.
Magic Kingdom on Monday was the cleaner version of the same pattern. We had no Lightning Lane bookings, just rope drop. We opened on Seven Dwarfs Mine Train into Haunted Mansion into Pirates of the Caribbean, and somewhere in the early afternoon noticed that Space Mountain's standby line was sitting at a manageable wait. We rode it three times.
Wednesday is just labeled "rest." It was also the day my parents drove down from South Carolina to spend with us — they live six hours away and pulled it off on a few hours' notice because they're on my Tailnet and had the same icon on their phones we did. They knew when we'd be where, without anyone having to text directions. When we ended up taking a long unplanned break from a different park to spend a couple of hours at the Polynesian pool and then come back for fireworks and dinner, the app didn't fight us. The plan was there to be deviated from, and the app's job was to be the place we deviated from, not the place we were trapped in.
The build
Vanilla HTML, plain CSS, a small app.js, a service worker, no framework, no bundler. Sixty mostly static pages don't need React, and a bundled SPA would have spent its first second of every visit hydrating instead of showing the page. The build step is cp -r. The whole site fits comfortably in a single browser cache.
The hosting is the part that almost nobody else is doing this way. The app serves out of an nginx:alpine container on my TrueNAS homelab, sidecar'd to a Tailscale container so the app publishes only onto our personal Tailnet. No public DNS, no ingress, no certificate to renew, no auth layer to write. My wife had Tailscale on her phone for weeks before the trip — it's how she gets to half the family infrastructure I run — so the install was the same as opening any other home page from a couch. I texted her the link, she handed me her phone, I tapped through the install. From that point on the app belonged to her device.
The service worker is the part that earned its keep in the parks. It's cache-first with explicit precaching: the install step grabs every HTML page, both font files, the four PDFs, and the favicons in one upfront pass. After that, every navigation reads from the cache before it touches the network. By the time my wife pulled up the 'Ohana page three days into the trip, that page had been on the device since installation. Nobody had to "open the menu first so it caches."
The "today" highlight is small but worth describing because it's the kind of detail that gets faked by accident. Computing "today" naively from new Date() and rendering it in the device's local timezone would have lit up the wrong card any time a phone wasn't already on Eastern. I compute it explicitly with Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }), then flip a data-today="true" attribute on the matching card if and only if today's date falls inside the trip window. Outside the window, no card lights up.
The look went through one bad version first — a warm editorial aesthetic with a serif display face and terracotta accents. It was nice. It also looked nothing like Disney. The re-theme leaned in: navy #0e1a33 dominant, storybook gold #c8a437 accent, castle blue #1f6cb2 for interactive states, on a paper cream surface. Type became Cinzel for display and Quicksand for body, both SIL OFL, self-hosted as variable woff2. Every text and background pair was checked against WCAG AA before shipping (gold was reserved for large display and decorative accents). A premium experience for the person whose standards are highest in our house cannot include squinting at a phone in line for Slinky Dog.
What I got wrong
The biggest miss was a Lightning Lane Multi Pass reminder, and it's the failure I think about most because it says something about the medium. At Hollywood Studios I burned my Tower of Terror LLMP redemption when the standby line was already at thirty minutes, assuming I could rebook the same ride for later. You can't — once you tap into a ride with LLMP, that ride is locked out of LLMP for the rest of the day, no matter how many other rides you cycle through in between. The deeper miss is that neither I nor the LLM I built the app with surfaced LLMPs as a scarce resource during planning. We both modeled them as a binary feature ("you have one, use it") rather than a small bag of high-leverage tokens where the timing of redemption matters as much as the act of redemption. A note on the Hollywood Studios page saying "if standby is under 45, cancel this LLMP and try to rebook it for the afternoon" would have been worth more than half the rest of the app. The transferable lesson is harder to write down than the LLMP rule itself: AI-paired development is excellent at building the thing you describe and bad at noticing what you forgot to describe. Closing that gap is the senior engineer's job, and on this trip I closed it one ride too late.
Two smaller misses. The app links downward (day → resort → restaurant) but doesn't link sideways. There's no easy "from this restaurant page, jump to the day we're eating there" or "from this park, see which day we're going." A web of related-page chips would let you wander the trip the way you actually think about it.
The PDF back-navigation gap is the one I discovered the wrong way: in line for a ride mid-trip, with my wife asking why she had to keep killing the app and reopening it just to close a park map. PDFs open in iOS Safari's PDF viewer, which has no path back to the installed PWA. I still don't have a clean answer for the right fix — opening PDFs from a PWA at all may have been the wrong call, and the alternatives I've considered (rendering the maps as inline SVG, building a custom in-app PDF viewer, wrapping the PDFs in a back-button shell) each carry their own ugliness. The next version of this app will have to pick its poison.
The interesting part of building this isn't that an LLM can write a service worker. It's that the engineering taste required to know which sixty pages to ask for, which framework not to use, why no public ingress is the right call for an app three people will use, and what "today in Orlando time" actually means on a flight, can now be compressed into a few evenings of directing — on top of the months of trip planning the app encodes.