cd ..
cat cineplan/README.md

Cineplan

A TMDB-backed discovery and planning app where the interesting work sits behind the UI — a caching proxy, and scheduled email reminders delivered out of band.

stack
React · TypeScript · Express · MongoDB · Redis · BullMQ · Docker
livesource

The product is a catalogue browser with a personal library on top. The parts worth talking about are the ones that keep a third-party API and a scheduled-work problem from leaking into the request path.

Proxying TMDB rather than calling it from the client

Every TMDB call goes through /api/tmdb/*. That keeps the API key server-side, but the more useful consequence is that responses get normalised before they’re cached. normalizeTitle flattens TMDB’s several shapes — a movie, a series, a search hit and a recommendation all describe a title differently — into one predictable object.

So the cache stores small, uniform entries rather than large raw payloads, the client renders a single shape instead of branching per endpoint, and per-resource TTLs can be tuned by how often that resource actually changes. The cache-aside layer sits over the normalised form, not the wire form.

Reminders belong outside the API

A user can schedule any watchlist title for a date and time and get an email when it’s due. Scheduling writes a BullMQ delayed job into Redis; a standalone reminder-worker process consumes the queue at the due moment and sends through Resend.

The API’s job ends when the job is enqueued. The worker re-checks state before sending (the title may have been watched or removed since), marks the reminder sent idempotently, and retries on failure — so a transient email outage doesn’t silently drop a reminder, and a retry doesn’t send a second copy. Because it’s a separate process, reminder delivery can fail, restart, or be scaled without touching request serving.

One Redis, three jobs

Redis backs the session store (connect-redis), the TMDB cache, and the BullMQ queue. That’s a deliberate consolidation for a single-VM deployment: three workloads that all want fast in-memory access, sharing one process instead of three, at the cost of coupling their failure domain — an acceptable trade at this scale and one that would be worth splitting if any single workload grew.

Front-end details that get skipped

Off-screen images are loading="lazy" and decoding="async", while the detail hero stays eager because it’s the LCP element. The trailer modal is a real dialog — role="dialog", aria-modal, Tab trapped inside, Esc and overlay both close it, background scroll locked, focus restored to the trigger on close. Cards are operable from the keyboard, and a global :focus-visible ring means you can always see where you are.