Carnival Ride — Changelog
Tracked starting v2.1. Earlier versions shipped without a changelog.
Unreleased
Added
- **Bug reporting + error monitoring via Sentry (BFLS-1301).** The floating "Report a bug" button is now Sentry's screenshot-capable widget (it replaces the old feedback button), and the app now automatically captures errors with a privacy-masked session replay so a bug on someone's phone can actually be diagnosed. Replays mask all text and block all media by default, and the guest invite token is scrubbed from everything Sentry sends. Fully no-op when the Sentry DSN isn't configured.
Fixed
- **The "Report a bug" button is always visible again (BFLS-1298).** After the Sentry switch the floating report button vanished on phones ("I don't see the chat icon anymore"). We'd routed it through Sentry's auto-injected widget, which only appears when the DSN is set *and* Sentry's actor button actually injects on the device — neither was reliable.
MobileShellnow renders our ownFeedbackFab**unconditionally** (it posts to/api/feedback→ the Supabasefeedbacktable + Slack, and **forwards each report into Sentry's User Feedback inbox** via a client-sideSentry.captureFeedbackso the report is linked to the user's active session replay + breadcrumbs), and Sentry's own feedback widget is set toautoInject: falseso it can't double up or silently fail. Shared with Fare. - **Screen wake lock retries after a tap (BFLS-1278).** The app-wide
<ScreenWakeLock />only re-tried acquiring the lock onvisibilitychange, so if iOS rejected the initial request right after a page load (document not yet "fully active") and the page then stayed visible, it never recovered. It now also re-acquires on the firstpointerdown/touchend(cheap no-op once a lock is held). **Caveat:** this does not overcome iOS **Low Power Mode** or in-browser Safari's general unreliability with the Wake Lock API — a reliable always-on screen needs the native app's idle-timer flag (tracked under BFLS-1274). Shared with Fare. - **Password reset works and the link points at the real app (BFLS-1310).** Tapping the emailed reset link sent the host "right back to the login page," and the link opened **localhost**. Two root causes: (1) the email was sent by Supabase's built-in mailer, whose "Site URL" was set to localhost; (2) the original PKCE
?code=flow needed thecode_verifiercookie from the exact browser that requested the reset, so opening it elsewhere failed. The reset email is now **sent by us through Resend with branded HTML** (emails/password-reset.ts): we mint the recovery token via the admingenerateLinkAPI and build the link from the app's own domain (getAppBaseUrl()), so it never points at localhost. The link runs the token-hash flow at/auth/confirm(verifyOtp), which works in any browser — including an email app's in-app browser. Expired/used links land back on the reset-request page ("request a new one"), not the login screen. The From header usesRESEND_FROM_ADDRESS(Carnival Ride <…>); pointing it atride@carnivalride.comis the remaining config step (verify the domain in Resend). - **Guests no longer see a map instead of the venue photo (BFLS-1309).** The shared
VenuePhototried the fresh proxy photo first and, on any failure (e.g. Places text-search finds no photo for that venue name), dropped *straight to the Mapbox map* — it never fell back to the originally-stored photo. Guests hit this more than hosts, who view right after creation while the proxy lookup is warm. The fallback is now staged — proxy → storedgoogle_photo_ref→ map — so a real venue photo shows whenever one is available. - **Vote-progress emails no longer look "behind" (BFLS-1305).** A host reported getting an email that "3 of 4 guests have voted" when all 4 actually had. The per-vote activity email reports a running tally read just after each vote commits; when two guests vote seconds apart, a non-final vote's email legitimately showed "3 of 4" while the 4th landed moments later — so it looked wrong against the live ride. The count is now computed the same way the ride's own auto-lock quorum does (distinct **non-declined** voters) and always includes the guest who just voted, so it can't undercount the action that triggered it; the copy now reads "…have voted so far" so it can't be misread as a final tally, and the separate "Everyone voted — time to choose" email remains the authoritative completion signal.
- **Venue photos no longer break on older rides (BFLS-1294 follow-up).** Newer rides showed a venue photo while older ones showed a broken-image thumbnail. Root cause: we stored Google's short-lived
photoUri(a signedgoogleusercontent.comURL) inrides.google_photo_refand rendered it later — it 403s once it expires. Venue photos now render through a stable proxy (/api/venue-photo?name=…&lat=…&lng=…) that re-resolves the photo from the stored venue name + coordinates at load time and streams the bytes through our own domain, so the URL never goes stale. Fixes existing rides with no data migration. Applied to the home cards (Thumb) and the sharedVenuePhoto(guest view, invitation-sent, create preview), each with a graceful fallback (letter avatar / Mapbox map) when a venue has no photo. Also stopped the/api/places/[placeId]/detailsroute from silently swallowing photo-fetch failures.
v2.1.5 — 2026-06-05
Fixed
- **Video recorder hardened against the iOS Safari camera failure (BFLS-1248 follow-up).** Reports that tapping Record showed nothing to start recording trace to documented WebKit defects (getUserMedia black-screen preview, a 2nd getUserMedia call killing the first, flaky MediaRecorder) — our code was correct but iOS left us in a silent black box (stuck in the
requestingstate with no record button). The in-app<VideoRecorder>now: shows a visible "Starting camera…" overlay, **times out after 12s** with a clear error instead of hanging, offers a **"Try camera again"** gesture-bound retry (the most reliable way to re-prompt iOS), stops any prior stream before re-requesting (avoids the 2nd-getUserMedia blanking bug), and sets themuted/playsinline/webkit-playsinlineattributes + plays onloadedmetadataso the preview actually paints. Still needs on-device confirmation; the new states make the exact failure diagnosable instead of invisible. The error/denied screens also show a compact on-screen diagnostic (secure context, getUserMedia/MediaRecorder support, and the exact error name) for a single-screenshot report — Vercel's log viewer truncates each/api/debug-logline, so reading the failure off the device screen is the reliable channel. **Logs then pinpointed the real failure:** on Chrome-for-iOS (CriOS) the camera opens, preview goes live, andMediaRecorder"starts" — but the clip comes back with **0 chunks / 0 bytes**. Fixes:recorder.start(250)(timeslice so iOS flushes data periodically instead of returning an empty blob), aMIN_RECORD_MSguard so an instant tap/ghost-click stop can't kill the clip, and an empty-result guard that shows a clear retry/Upload message (and, on CriOS, tells the host to open in Safari) instead of completing with a 0-byte video. **On-device lifecycle logs then revealed the real blocker for the preview never appearing:** the single tap on "Record" was firing a ghost click onto the just-mounted recorder's Cancel control ~10 ms later (iOS replays the tap onto the newly-rendered view), which closed the recorder beforegetUserMediaresolved — so it looked like "camera allowed, then nothing happens." Fixed with a 600 ms post-open guard on Cancel (handleCancelinvideo-recorder.tsx) so the open-tap can't immediately close the recorder — and re-armed on the **red record-button tap** too, since the same ghost click was firing there and tearing the recorder down mid-record (empty clip). Finally, the live preview was switched fromobject-covertoobject-containso it shows the **full camera frame** (background and all) that actually gets recorded, instead of zooming/cropping to the host's face — the preview now matches the saved clip.
Changed
- **Screen stays awake across the whole app (BFLS-1278).** The screen no longer dims/sleeps while the app is open — not just during video recording. A new app-wide
<ScreenWakeLock />(in@carnival/ui-shell) is mounted in the root layout and holds anavigator.wakeLocksentinel the entire time the app is foregrounded, re-acquiring onvisibilitychange. Leaving/backgrounding the app releases the lock so normal sleep resumes ("revert to normal shut off when all is complete"). No-ops where the Wake Lock API is unsupported (iOS Safari < 16.4, non-secure contexts), so no regression. Shared with Fare. - **Venue search dismisses the keyboard (BFLS-1294).** When choosing a venue, firing the search (the mobile keyboard's blue Search key / form submit) now blurs the search input so the on-screen keyboard drops away and the results list is fully visible.
handleSearchSubmitinvenue-map-modal.tsxcallssearchInputRef.current?.blur()before running the query. - **Guests must answer every proposed time before saving (BFLS-1297).** On the guest voting screen, "Save my choices" is now disabled until *every* time option has an explicit **Yes** or **No** — partial responses can no longer be submitted. The helper line under the list now counts what's left ("2 times left to answer"), then falls back to the existing "Tap Yes on at least one" / "N yes" copy once all are answered. The submit handler also guards server-side-style ("Choose Yes or No for every time before saving."). Completeness is measured against the live option list so a stale choice for a removed option can't satisfy the gate.
- **Cost-splitting UI hidden in create-ride for now (BFLS-1295, BFLS-1296).** The host create-ride flow no longer shows the payment-splitting controls. On the **Guests** step the "Splitting costs later?" payout-method picker, the payout-handle input, and the "Message preview" card are removed; on the **Review** step (step 5) the **Payout** summary row and its inline Edit link are removed. Cost-splitting is deferred — the underlying
draft.payoutMethod/payoutHandlestill default cleanly so ride creation is unaffected; the blocks are restorable from git when splitting returns. - **Swipe-left to Cancel on active home ride rows, with a visible red Cancel (BFLS-1272 follow-up).** The "Your turn" / "In progress" ride rows now support a Gmail-style left-swipe that reveals a solid-red **Cancel** action; tapping it opens the "Cancel this ride?" confirmation (unchanged copy). The History-card Cancel was also recolored to a clear red pill. A horizontal swipe suppresses the card's navigation tap (so swiping never opens the ride), and
touch-action: pan-ykeeps vertical scrolling intact. The confirm sheet is now a single sharedCancelRideConfirmused by both the swipe action and the History card.
v2.1.4 — 2026-06-01
Fixed
- **Host video opens the front camera on mobile (BFLS-1248, follow-up).** The previous fix added
facingMode: { ideal: 'user' }to the in-app<VideoRecorder>, but on a phone the host never reached it — the create-ride video step hid "Record" on mobile and offered only the OS file picker (<input type="file" accept="video/*">), which opens rear-camera-first and can't be aimed by the page. The video step now shows **Record** (primary) on mobile too — it launches the in-app recorder, which requests the selfie camera and holds a Wake Lock while recording (also satisfies BFLS-1278 on the Ride side) — with **Upload a file** as the secondary fallback (gallery / existing video, and a safety net if the live recorder won't start on a device). Note: in-browserMediaRecorderreliability on iOS Safari is the real-device variable; the Upload fallback means worst case is "use Upload," not a hard block. - **Draft "Resume" now restores your work in one tap (BFLS-1283).** Tapping **Resume** on the home-page "You have a draft in progress" banner dropped the host on a blank create-ride form — it looked like everything (title, venue, video, guests, times) was lost. The banner linked to
/rides/newwith no resume signal, and the wizard only set a flag on mount; the saved draft was hydrated only when the host tapped a *second*, in-wizard "Resume". The home Resume now links to/rides/new?resume=1and the wizard auto-hydrates the saved draft on mount when that flag is present, so a single tap restores every field. A direct/rides/newvisit still shows the in-wizard banner (resume vs. start fresh). No data was ever actually lost — the draft lives in localStorage and the host video is a persisted Vercel Blob URL, not a transient object URL. - **Selecting a contact no longer pre-fills both phone and email (BFLS-1277).** When a host expanded a multi-channel contact in the guest picker, both the primary phone and the primary email were pre-checked, so a one-tap "Add" attached both channels by default — hosts wanted to choose. Now only the primary **phone** is pre-checked (email is opt-in); a contact with no phone falls back to pre-checking its email. Applied identically to both entry points: the inline
GuestContactSearchdropdown and the full-screenGuestContactPickerModal. - **A guest you already added stops reappearing in Recent / Google results (BFLS-1276).** The contact picker had no awareness of who was already on the ride, so a just-added person stayed in the "Recent guests" list (and Google Contacts results) and could be added again.
GuestContactSearch/GuestContactPickerModalnow take anexcludeContactsprop; the host create-ride flow passes the already-added guests, and matches (by normalized phone or lowercased email) are filtered out of both Recent and Google results.
Added
- **Host gets a summary email when the ride locks (BFLS-1281).** When a ride locks (deadline cron or manual), the host now receives a Resend email — alongside the existing guest notifications — recapping the **winning time**, **ride + venue name** (with the venue photo when available), the **full vote breakdown** (votes per option, winner highlighted), and the roster split into **who's coming** (voted for the winning time) vs **couldn't make this time**. New builder
emails/host-locked.ts, sent fromhandleRideLockedinride-locked.ts; shares that function's once-per-lock invocation so it can't double-send. Channel decision: the host always has an email on file (captured at ride creation) but we don't store a host phone, so this is **email-only** — there's no host SMS (the app works fully on email for the host). - **Cancel a ride straight from its home card (BFLS-1272).** Snoozed/active ride cards in the home "History" row now show a **Cancel** action next to "Restore to home". Tapping it opens a confirm sheet ("Cancel this ride? Everyone — including guests who couldn't make it — gets a text that <ride> at <venue> is cancelled."); confirming calls the existing
cancelRideaction, which texts every non-declined guest that the plan is off. Cancel only appears while a ride is still cancellable (hidden once payment is underway:receipt_uploaded/claiming/payment_pending/completed/cancelled), mirroring the server gate from BFLS-1020.
Changed (Fare-only, no Ride changes this round)
- **BFLS-1282 (fix) — Fare only this round.** Fare's "Start over" on the stuck "Reading receipt…" screen now actually escapes — it routes to the photo re-upload step instead of looping straight back onto the spinner. Ride has no user-facing changes this round; version pinned for parity.
- **BFLS-1282 / BFLS-1278 (Fare side) — Fare only this round.** Fare gained a "Cancel and start over" escape on its stuck "Reading receipt…" screen and a screen wake-lock during OCR. Ride has no user-facing changes for these; the Ride side of BFLS-1278 (wake-lock while recording) already shipped with BFLS-1248. Version pinned for parity.
Changed
- **Home page: Recent Activity is collapsible and moved to the bottom; count-tile scoreboard removed (BFLS-1271).** The large "Recent Activity" feed previously sat near the top and pushed everything below it off-screen. It now lives at the bottom of the page and is **collapsed by default** — a tappable header shows "Recent activity · N new" with a chevron, and expands the feed on tap. The "YOUR TURN / IN PROGRESS / SETTLED" count-tile scoreboard (the
Snapshotblock) has been removed; the actual ride-card sections that share those titles ("Your turn", "In progress") are unchanged. - **Invite SMS greets the guest by name (BFLS-1279).** The invite text opened with the host's name ("Farzad would like to invite you to…") with no mention of the recipient. It now leads with the guest's first name when one is on file — "Hi Neda, Farzad would like to invite you to Ride 5 at …" — mirroring the invite email, which already greets by first name. Stays GSM-7/ASCII; falls back to the un-greeted opener when the guest has no display name.
v2.1.3 — 2026-05-27
Fixed
- **Guest "Done" button now produces a real thank-you state (BFLS-1265).** On the guest invite page, after submitting picks the host-detail confirmation card showed two buttons: "Edit my picks" (worked) and "Done" (called
window.scrollTo({top:0})only — a no-op since the user was already at the top, so the button felt broken).Donenow flips the view into a terminal thank-you screen (thankedstate inguest-ride-view.tsx) with the copy "Thank you! We'll text you the moment {host} chooses the final time. You can close this page now." A small underlined text link below — "Change my choices" — lets the guest rewind to the voting screen before the deadline; the link disables once the response window closes. - **Last page of host preview broken down into per-element Edit links (BFLS-1262).** The Review step's "How guests see it" card previously had a single "Edit venue" link in its corner, leaving the title, video note, and times silently uneditable from preview (all Edit taps jumped to the venue step regardless of what the host was trying to fix). The preview is now split into four sub-sections with their own inline Edit links: **Title** → jumps back to Details, **Venue** (name/address/photo) → jumps to Venue, **Note** (video) → jumps to Details, **Times** → jumps to Timing (unchanged).
- **"Next" now lands the host at the top of the new step (BFLS-1261).** The 5-step host create-ride flow used
behavior: 'smooth'when snapping the viewport on step change, which on mobile Safari interleaves with React's commit of the new step's DOM and leaves the page stranded mid-scroll. Hosts thought the Next tap didn't take and double-tapped.scrollToTop()inhost-create-ride-flow.tsxnow fires instantly (no animation), then re-issues the scroll on the next animation frame so any post-commit layout shift can't drag the viewport off-top. - **Initial video camera prefers the front-facing lens, screen stays awake (BFLS-1248).** The in-app
<VideoRecorder>now requestsgetUserMedia({ video: { facingMode: { ideal: 'user' } } })so iPhones open the selfie camera by default for the host's hello video — previously the constraint was unset and iOS picked the rear lens.ideal(notexact) preserves the fallback path on devices without a front camera. Recording also now acquires anavigator.wakeLock.request('screen')sentinel on start, releases on stop/unmount, and re-acquires onvisibilitychangeso the screen no longer auto-sleeps mid-record. Wake Lock has been supported on iOS Safari since 16.4 and on Chrome since 84. The OS-owned file picker fallback (used when the host taps Upload from gallery) is unchanged — that camera direction is controlled by iOS, not the page.
Changed
- **Renamed "Pick" / "Picks" → "Choose" / "Choices" everywhere (BFLS-1263).** User-facing copy across Ride: voting confirmation card ("Save my picks" → "Save my choices", "Your picks are saved" → "Your choices are saved", "Edit my picks" → "Edit my choices", "Update my picks" → "Update my choices"), host detail page push notifications ("Pick the winning time" → "Choose the winning time", "${name} picked their times" → "${name} chose their times", "Pick a date and time." → "Choose a date and time."), host home cards ("Pick payment method" → "Choose payment method", "Pick payment" → "Choose payment", "Pick how guests should pay you" → "Choose how guests should pay you", "Guests picked times — lock one in" → "Guests chose times — lock one in", "Everyone is done picking" → "Everyone is done choosing", "All picked — close claiming" → "All chosen — close claiming", "Pick a spot, share a few times" → "Choose a spot, share a few times", "Pick a showtime together" → "Choose a showtime together"), host create-ride flow step copy ("Pick ALL the dates/times" → "Choose ALL the dates/times", "Pick every single date and time" → "Choose every single date and time", "Pick the spot" → "Choose the spot", "Pick a venue" → "Choose a venue", "Pick from calendar" → "Choose from calendar", "Pick a future date or time" → "Choose a future date or time", "Pick a time for…" → "Choose a time for…", "Pick 2 or more times" → "Choose 2 or more times", "Pick up where you left off" → "Resume where you left off", "No venue picked yet" → "No venue chosen yet"), notifications/insert-notification.ts feed labels ("picked their times" → "chose their times", "updated their picks" → "updated their choices", "time to pick the winner" → "time to choose the winner", "finished picking items" → "finished choosing items", "finished picking — time to close" → "finished choosing — time to close"), notification bell + recent activity CTA ("Pick winner" → "Choose winner"), deadline picker tooltip ("Picks in the past" → "Choices in the past"), CSV contact import error ("Pick at least one contact" → "Choose at least one contact"), Google Contacts picker title ("Pick from Google Contacts" → "Choose from Google Contacts"), guest contact picker modal ("Pick a contact" → "Choose a contact"), venue surfaces ("Pick a venue" / "Pick this venue →" → "Choose a venue" / "Choose this venue →"), feedback validation ("Pick a feedback type." → "Choose a feedback type."), password reset confirm copy ("Pick something at least 8 characters long" → "Choose something at least 8 characters long"), response window card ("the winner will be re-picked" → "the winner will be re-chosen"), invite SMS body ("You can pick up to N times" → "You can choose up to N times"), invite email ("Pick a time" → "Choose a time", "pick what works" → "choose what works", "RSVP & pick a time" → "RSVP & choose a time"), receipt-video email ("Watch the note & pick yours" / "Pick what you had" / "Watch and pick:" → "Watch the note & choose yours" / "Choose what you had" / "Watch and choose:"), host-notifications email ("Everyone voted — time to pick" → "Everyone voted — time to choose", "${guestName} picked their times" → "${guestName} chose their times", "They picked:" → "They chose:", "Pick the winning time" → "Choose the winning time"), invite email response-deadline label ("Carnival picks the night with the most votes" → "Carnival chooses the night with the most votes"), test send-all-emails route reminder copy ("is still waiting on your pick" → "is still waiting on your choice", "Pick a night" → "Choose a night", "Tap to pick a night" → "Tap to choose a night", "Carnival picks the night with the most votes" → "Carnival chooses the night with the most votes"). Code comments and internal symbol names (variables like
picks,watchAndPickUrl, thepicking_done/all_pickednotification event keys, thetime-picked.tsfilename) were intentionally left alone — only end-user-visible strings flipped.
v2.1.2 — 2026-05-25
Changed
- **Host can cancel after the ride locks (BFLS-1020).** The "Cancel this ride" surface on the host detail page was previously gated on
!isLocked && status !== 'cancelled', so once the deadline-lock cron flipped the ride tolockeda host who needed to bail (sick, schedule conflict, plans fell through) had no in-app exit and had to manually message guests. The gate now uses!isPaymentInFlight(status), which keeps cancel available acrossvoting/locked/expiredand only hides it once payment is underway (receipt_uploaded/claiming/payment_pending/completed) or the ride is already cancelled.isPaymentInFlightis now exported fromhost-ride-detail.tsxso the cancel gate, the response-window edit gate, and any future "is the bill underway?" check share one source of truth. ServercancelRideImplgot matching defence-in-depth: rejectsINVALID_INPUTfor post-receipt statuses ("Can't cancel — payment is already underway. Refunds need to happen outside the app.") and for already-cancelled rides (idempotent error instead of a silent re-fire of the SMS fan-out). The cancellation SMS fallback string also flipped from "the dinner" to "the plan" so it stays activity-agnostic with the BFLS-1045 copy sweep.
Fixed
- **Host can edit the response window after the ride locks (BFLS-1249).** Two bugs in one ticket. (1) On the host ride-detail page, the Response window card disappeared entirely once
ride.statusflipped tolockedorexpired, so a host who needed to push the deadline out had no surface to do it. The card now stays visible throughlocked/expired(it's hidden only once payment is in flight:receipt_uploaded/claiming/payment_pending/completed/cancelled), shows a smallLocked/Expiredchip, swaps the "Edit" link to "Adjust deadline", and tells the host inline that saving with a deadline in the future will re-open voting.updateResponseWindownow detects the locked → future-deadline case, flipsstatusback tovoting, and clearsselected_time_option_id+auto_locked_atso the deadline-lock cron picks a winner fresh when the new deadline elapses; a past-deadline edit on a locked ride stays cosmetic and the lock holds. (2) On the Reminder picker, the 24h / 6h / 1h preset chips were disabled (disabled={true}) whenever the resulting reminder would be in the past, so a host whose existing reminder was past — or whose earliest event was inside the preset's window — had no way to escape via the chips: "1 hour before" worked because the event was still >1h away, "Custom" worked because it never gated, but the other presets were dead. Chips now stay clickable; the existing past-reminder status chip + rescue link below already cover the soft-validation warning, matching the deadline-picker behaviour documented in BFLS-1236.
Changed
- **Guest invite: 3-card layout — brand → video → venue photo (BFLS-1250 follow-up).** Both
VotingView(/g/[guestToken]pre-vote) andLockedView(post-host-pick) on the guest invite page now render three stacked cards in this order: (1) Carnival Ride brand + invitation header (title + venue name + address), (2) the host's video note with "A note from {host}" caption, (3) the venue/Google Places photo. Previously v2.1.1 had moved the video card above a single bundled brand+title+venue+photo card, which left the video floating orphaned above the brand. The new structure puts the Carnival Ride identity at the top, lets the host's video be its own beat, and gives the venue photo its own card so guests parse three clear chapters: who's inviting → what they said → where it is. LockedView also surfaces the host's video for the first time (previously hidden post-lock). Cards 2 and 3 only render when their content exists, so rides without a video or without a venue photo don't show empty cards.
v2.1.1 — 2026-05-25
Changed
- **Guest voting is now Yes/No per time (BFLS-1252).** The
/g/[guestToken]voting card replaces the single-checkbox-per-row model with a Yes/No segmented pair next to each time option. The eyebrow above the list now reads "Select **yes** to **all** the times you can do and **no** to the others." A row tapped No fades to muted with a strike-through label so the guest can see they've already answered it. Tapping the same choice twice clears the row back to undecided. Submit unlocks as soon as one Yes is tapped (previously 2). The server still persists only the Yeses, so "No" is purely client-side visual feedback — on a return visit, un-Yes'd rows display as undecided again. Min-vote validation inrecordMultiVoteImpldropped from 2 to 1. - **Guest invite SMS body trimmed (BFLS-1251).** The outbound invite SMS no longer lists every proposed time option or includes a separate video link. Times were redundant — guests see them on the invite page with full tz formatting — and the bare-list version cluttered notification previews. The body now reads:
{Host} would like to invite you to {title} at {venue}. You can pick up to {N} times. Respond by {deadline}. Click below to see the details: {invite-url}. The video, when present, still plays on the branded invite page (/i/<token>→/g/<full>) so no functionality is lost — just one fewer link in the SMS. - **Guest invite: video shows above venue (BFLS-1250).** On
/g/[guestToken]the host's video note is now the first card a guest sees when they open the link, with the title + venue + venue photo card right below it. Previously the venue card came first and the video sat further down the scroll. The venue card itself (Wordmark + title + venue text + photo) is unchanged — only the order swapped. When a ride has no video, the page is unchanged from before.
Fixed
- **Cached Range response broke video playback in viewer (BFLS-1247).** When a guest clicked the branded video URL, the browser's
<video>element fired its first fetch as a Range request (HTTPRange: bytes=0-1023for the moov atom). Vercel's CDN cached that 1024-byte slice under the URL — without keying byRange— and served the partial response for every subsequent request, dropping the206status down to200. Result: video element thought the whole file was 1024 bytes, played audio but rendered black frames. Fix inapps/ride/src/app/v/[...path]/route.ts: 206 responses now shipCache-Control: private, no-store, must-revalidate(don't cache partial fetches — they're tiny, fresh-on-every-request is fine), andVary: Rangeis set on all responses so any downstream cache that keys by Range still does the right thing. 200 full responses stay immutable-cached.
Added
- **Branded video viewer page for SMS/email links (BFLS-1257).** The video link in invite and reminder messages now opens
https://ride.carnivaldelight.com/v/p/<rideId>?t=<token>— a Carnival Delight–branded page wrapping the video with the ride title, host name, venue, and proposed time options, plus an "Open invitation →" CTA back to the guest's/i/<token>invite page. Replaces the previous behaviour of opening the raw.mp4in the browser's built-in player (black bars, no context). Click-to-play (no silent autoplay — host voice messages need user gesture); poster image fills a 9:16 portrait frame. Public access — middleware matcher already skips/v/*. The underlying/v/[...path]edge proxy still serves the actual video bytes from inside the viewer. - **Branded URLs for video links in SMS/email (BFLS-1247).** Outbound invites and reminders now ship
https://ride.carnivaldelight.com/v/rides/<id>/invite-video-<8hex>.webminstead of the raw*.public.blob.vercel-storage.comhost. The/v/*path proxies to the new Blob store (store_BAYudWOrfXt26lQw) via a Next.jsrewrites()rule inapps/ride/next.config.ts; middleware excludes the prefix so video bytes don't pay for Supabase auth or pick upSet-Cookie(keeps Vercel's edge cache warm). AggressiveCache-Control: public, max-age=31536000, immutableon/v/*mitigates the external-rewrite double-bandwidth charge — Blob pathnames are content-addressed (random 8-hex suffix per upload) so they're safely immutable. Server-only envBLOB_PUBLIC_HOSTcontrols the branded base (host-only or host+prefix); legacy rides with raw blob URLs in the DB get rewritten on the way out byapplyBlobPublicHost()in@carnival/env. Unset env = no-op.
Fixed
- **Timezone bug in guest SMS/email (BFLS-1246).** Reminder, invite, and lock-confirmation messages used to render times in Node's UTC mis-labeled as a local time — a guest in PDT saw "closes 10:49 PM" when the actual lock was 3:49 PM their time. Now every outbound message renders in the guest's stored IANA timezone (captured the first time they open their invite link), falling back to the host's timezone, then to
America/New_York. Google Calendar invites use the host's timezone for the event's authoring zone instead of the Vercel function's OS zone. - **Invite email per-option cards + invite SMS option list (BFLS-1246 follow-up).** The "TUE 26 May · 12:00 PM" cards in the invite email and the "Options: …" list in the invite SMS were rendering from the host's pre-stored label string with no timezone suffix, so a guest in EST opening an invite built by a PT host had no idea which 12:00 PM the host meant. Each option is now reformatted from its UTC
starts_atin the guest's resolved zone with the abbreviation (e.g. "Tue · 3:00 PM EST"). Falls back to the stored label for legacy rides wherestarts_atis null. - **Guest voting page time options.**
/g/[guestToken]cards now render each option fromstarts_atin the viewer's browser timezone with the abbreviation suffix. Same change applies to the "you picked X" confirmation, the locked-view winner label, and the full-tally rows.
Added
rides.host_timezoneandride_participants.timezonecolumns. Host's tz is captured at ride creation; each guest's tz is captured fire-and-forget the first time they open/g/[guestToken].- Public
POST /api/participants/[token]/timezonefor guest TZ capture (invite token is the bearer). - Deadline + reminder chips in the host UI now suffix the viewer's tz abbreviation (e.g. "EST") so the chip can't be misread as a different zone.
- Host wizard's
formatTimeLabelnow bakes the host's tz abbreviation into the storedride_time_options.label, so legacy consumers reading the label string still display zone context.
v2.1.0 — 2026-05-24
Added
- Notification audit mirror. When the server-only env var
NOTIFICATION_AUDIT_EMAILis set, every outbound guest invite, reminder, time-locked confirmation, calendar (.ics) invite, host activity email, and ad-hoc SMS is mirrored to that address so the team can monitor traffic from a single inbox. Failure to mirror never blocks the primary send. - In-app version label in the footer linking to this changelog.
Changed
- Dev
/api/sendtest endpoint now goes through the sharedsendResendEmail()chokepoint so test sends also receive the audit BCC.
Notes
- Supabase Auth emails (signup confirm, password reset) ship via Supabase's own SMTP and are not captured by the audit mirror.