<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Inside GusLift </title>
    <description>The latest articles on DEV Community by Inside GusLift  (@guslift).</description>
    <link>https://dev.to/guslift</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F12638%2F8fe1e15c-f741-4255-b69c-7797ea10b4d3.png</url>
      <title>DEV Community: Inside GusLift </title>
      <link>https://dev.to/guslift</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/guslift"/>
    <language>en</language>
    <item>
      <title>Week 7: How GusLift Handles Payment Without a Payment Processor</title>
      <dc:creator>Amanuel Demissie</dc:creator>
      <pubDate>Sat, 23 May 2026 10:29:57 +0000</pubDate>
      <link>https://dev.to/guslift/week-7-how-guslift-handles-payment-without-a-payment-processor-4n66</link>
      <guid>https://dev.to/guslift/week-7-how-guslift-handles-payment-without-a-payment-processor-4n66</guid>
      <description>&lt;p&gt;&lt;code&gt;#mobiledevelopment&lt;/code&gt; &lt;code&gt;#backenddevelopment&lt;/code&gt; &lt;code&gt;#database&lt;/code&gt; &lt;code&gt;#infrastructure&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Campus rideshare is informal. Nobody wants to enter a card number. But "figure it out after the ride" doesn't work either — it creates friction and confusion at drop-off. We needed a payment flow that's lightweight, works with Venmo and CashApp, and doesn't require GusLift to touch money.&lt;/p&gt;

&lt;p&gt;Here's what we built.&lt;/p&gt;

&lt;p&gt;Fare Calculation&lt;/p&gt;

&lt;p&gt;Fares are computed from the straight-line distance between two campus buildings using the Haversine formula. The base fare is $1.00. Distance adds $0.75 per mile on top. All 20 Augustana buildings have precomputed lat/lng coordinates, so the fare for any route is deterministic and computable on the client with no API call.&lt;/p&gt;

&lt;p&gt;The rider sees an estimated fare on the home screen before they even request a ride, calculated from their saved commute. That number is confirmed again on the ride detail screen once a match is made.&lt;/p&gt;

&lt;p&gt;Driver Payout Settings&lt;/p&gt;

&lt;p&gt;Drivers configure their preferred payment method once — Venmo handle, CashApp tag, Zelle phone number, or "cash." This is stored in a &lt;code&gt;driver_payment_settings&lt;/code&gt; table in Postgres. When a rider views a confirmed ride, they see which method the driver accepts and the amount to send.&lt;/p&gt;

&lt;p&gt;We also scaffolded Stripe Connect routes for future use, but that path isn't active. For now, all transactions happen off-platform.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz7uo96ov11gitk5mxbom.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz7uo96ov11gitk5mxbom.png" alt=" " width="753" height="626"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Verbal Code&lt;/p&gt;

&lt;p&gt;The rider generates a 4-digit code through the app after paying. The code is written to the Rides table and marked &lt;code&gt;code_issued&lt;/code&gt;. At drop-off, the rider reads the code to the driver, who enters it in the driver ride detail screen. The backend verifies the code matches, marks the ride &lt;code&gt;payment_verified&lt;/code&gt;, and that's it.&lt;/p&gt;

&lt;p&gt;There's no escrow, no hold, no dispute mechanism. The code is purely a lightweight confirmation signal: the rider clicked "I paid" and got a code; the driver confirmed they heard it. For a campus app where both sides know each other by face, that's sufficient.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F80e0w4nwonyl80ux53ny.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F80e0w4nwonyl80ux53ny.png" alt=" " width="753" height="626"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Schema Changes&lt;/p&gt;

&lt;p&gt;Four new columns on Rides: &lt;code&gt;fare_cents&lt;/code&gt;, &lt;code&gt;payment_code&lt;/code&gt;, &lt;code&gt;payment_status&lt;/code&gt;, &lt;code&gt;payment_method&lt;/code&gt;. One new table: &lt;code&gt;driver_payment_settings&lt;/code&gt;. The ride history screens for both roles now surface payment status on completed rides.&lt;/p&gt;

&lt;p&gt;What's Left&lt;/p&gt;

&lt;p&gt;The flow assumes the rider pays before the ride ends and that the driver actually checks the code. There's no enforcement if either side skips. For pilot scale — a few dozen rides a week — that's acceptable. Rate limiting on code generation and expiry on unverified codes are the obvious next additions.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>backend</category>
      <category>mobile</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>Week 7: How GusLift Shows Your Route in Real Time</title>
      <dc:creator>Amanuel Demissie</dc:creator>
      <pubDate>Sat, 23 May 2026 10:25:24 +0000</pubDate>
      <link>https://dev.to/guslift/week-7-how-guslift-shows-your-route-in-real-time-jnh</link>
      <guid>https://dev.to/guslift/week-7-how-guslift-shows-your-route-in-real-time-jnh</guid>
      <description>&lt;p&gt;&lt;code&gt;#reactnative&lt;/code&gt; &lt;code&gt;#maps&lt;/code&gt; &lt;code&gt;#crossplatform&lt;/code&gt; &lt;code&gt;#mobiledevelopment&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Every ride request involves two locations. The matching screen was showing those as text. Adding a map that actually shows both pins — pickup in green, dropoff in red, other participants in blue — is a small change in concept but a surprisingly tricky one in practice.&lt;/p&gt;

&lt;p&gt;Here's how we got there.&lt;/p&gt;

&lt;p&gt;The Native Maps Dead End&lt;/p&gt;

&lt;p&gt;The first attempt used react-native-maps backed by Google Maps. It worked on iOS and Android simulators. It didn't work on web at all, and the native build requires an API key wired into the native layer, which adds friction every time someone clones the repo. After getting it running, we ripped it out entirely.&lt;/p&gt;

&lt;p&gt;What We Replaced It With&lt;/p&gt;

&lt;p&gt;The map is now a WebView embed running Leaflet over OpenStreetMap tiles. On web it renders as a plain &lt;code&gt;&amp;lt;iframe srcDoc&amp;gt;&lt;/code&gt;. On iOS and Android it uses react-native-webview. Same HTML, same behavior, no API key required, no native build step.&lt;/p&gt;

&lt;p&gt;The map HTML is generated at runtime. We compute a bounding box from all the pins, set the center and zoom dynamically, and inject the markers as JavaScript into the Leaflet page before it loads. Each pin is a custom SVG — a teardrop shape with a white circle — colored by role.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8sdc462763nq2r0ahbvc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8sdc462763nq2r0ahbvc.png" alt=" " width="726" height="1590"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Coordinate Layer&lt;/p&gt;

&lt;p&gt;Campus buildings don't have addresses that geocoding handles well. We precomputed lat/lng for all 20 Augustana buildings from OpenStreetMap and stored them in a static lookup table — &lt;code&gt;campusCoords.js&lt;/code&gt;. Every pin resolves through that table. No geocoding at runtime, no API call, no latency.&lt;/p&gt;

&lt;p&gt;Where Maps Appear&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RouteMap&lt;/code&gt; is a single reusable component that takes &lt;code&gt;pickup&lt;/code&gt;, &lt;code&gt;dropoff&lt;/code&gt;, and an optional &lt;code&gt;extraMarkers&lt;/code&gt; array. It appears on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The rider and driver waiting rooms (your own route while you wait)&lt;/li&gt;
&lt;li&gt;The ride detail screens (confirmed route after match)&lt;/li&gt;
&lt;li&gt;The available riders/drivers screens (driver sees all rider pickup pins; rider sees the driver's pickup pin in purple once matched)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The matching worker was extended to broadcast &lt;code&gt;pickup_loc&lt;/code&gt; alongside &lt;code&gt;to_location&lt;/code&gt; over WebSocket so that map pins for other participants are available on match screens without an extra DB fetch.&lt;/p&gt;




&lt;p&gt;What Didn't Change&lt;/p&gt;

&lt;p&gt;State, matching logic, and the Durable Object model are untouched. The map is purely display — it reads locations already present in the app state and renders them.&lt;/p&gt;

</description>
      <category>devjournal</category>
      <category>mobile</category>
      <category>reactnative</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Strengthening Ride Matching, Notifications, and Schedule Management</title>
      <dc:creator>Nathan Sisay</dc:creator>
      <pubDate>Fri, 22 May 2026 03:17:59 +0000</pubDate>
      <link>https://dev.to/guslift/strengthening-ride-matching-notifications-and-schedule-management-27h7</link>
      <guid>https://dev.to/guslift/strengthening-ride-matching-notifications-and-schedule-management-27h7</guid>
      <description>&lt;p&gt;For our last week, focused on improving session persistence and preparing GusLift for broader platform support. This week, we continued improving the core ride-sharing experience by working on notifications, routing, schedule management, ride matching, and user interface consistency.&lt;/p&gt;

&lt;h1&gt;
  
  
  Improving Push Notifications
&lt;/h1&gt;

&lt;p&gt;One major focus this week was improving push notification support.&lt;/p&gt;

&lt;p&gt;Push notifications are important for a ride-sharing app because riders and drivers need to receive updates quickly. For example, a rider should know when a driver responds to a ride request, and a driver should know when there is activity related to a potential ride.&lt;/p&gt;

&lt;p&gt;To support this, the app was configured to work with Expo notifications and Firebase notification services for Android.&lt;/p&gt;

&lt;p&gt;On the backend, notification token handling was added so the app can store device push tokens. This allows the system to know where notifications should be sent when ride or matching events happen.&lt;/p&gt;

&lt;p&gt;The matching worker was also updated to support push notification behavior, helping connect real-time ride matching events with user-facing alerts.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why Notifications Matter
&lt;/h1&gt;

&lt;p&gt;Without notifications, users would need to constantly keep checking the app for updates.&lt;br&gt;
That would make the ride experience slower and less reliable.&lt;br&gt;
By adding notification support, GusLift can begin moving toward a more real-time experience where users are alerted when something important happens.&lt;/p&gt;

&lt;p&gt;This is important for matching riders and drivers, since timing matters when coordinating rides.&lt;/p&gt;

&lt;h1&gt;
  
  
  Improving Routing and Login Flow
&lt;/h1&gt;

&lt;p&gt;Another major area of work was improving how users move through the app after logging in.&lt;/p&gt;

&lt;p&gt;Several routing fixes were made so riders and drivers land on the correct screens based on their role and setup status.&lt;/p&gt;

&lt;p&gt;For riders, the app was updated so they consistently land on RiderHome instead of being sent to the older RequestRide screen. Older entry points to the previous request screen were removed or redirected.&lt;/p&gt;

&lt;p&gt;A new route helper was also added to centralize user routing logic. This makes the app easier to maintain because routing decisions are handled in one place instead of being scattered across multiple screens.&lt;/p&gt;

&lt;h1&gt;
  
  
  Improving Ride Matching Visibility
&lt;/h1&gt;

&lt;p&gt;This week also included improvements to the ride matching experience.&lt;/p&gt;

&lt;p&gt;The app now shows pending driver matches more clearly on rider home and scheduled rides screens. This helps riders understand when a match is waiting for action instead of hiding it deeper in the app.&lt;/p&gt;

&lt;p&gt;There were also fixes to prevent drivers from re-selecting riders who already rejected them.This makes the matching process feel more logical and prevents confusing repeat interactions between riders and drivers.&lt;/p&gt;

&lt;h1&gt;
  
  
  Updating Schedule Management
&lt;/h1&gt;

&lt;p&gt;Schedule management was another important improvement.&lt;br&gt;
The older change-schedule flow was replaced with a cleaner view and edit schedule experience. This gives both riders and drivers a better way to manage their availability and planned rides.&lt;br&gt;
The schedule screens were also connected more cleanly from the rider and driver home pages.&lt;/p&gt;

&lt;p&gt;This matters because scheduling is one of the core features of GusLift. Riders need to plan rides, and drivers need to manage when they are available.&lt;/p&gt;

&lt;h1&gt;
  
  
  Improving Time Display and Ride History
&lt;/h1&gt;

&lt;p&gt;Many commits focused on fixing time display issues.&lt;br&gt;
Waiting room times, manual ride times, and ride history display were updated so users see more accurate and consistent information.&lt;br&gt;
This is especially important for a ride-sharing app because incorrect time information can quickly make the experience confusing.&lt;br&gt;
Ride history was also improved so past ride information displays more reliably for users.&lt;/p&gt;

&lt;h1&gt;
  
  
  User Interface Improvements
&lt;/h1&gt;

&lt;p&gt;We also made a lot of visual and layout improvements.&lt;br&gt;
Rider screens were restyled for better color and layout consistency. Home screens and waiting room screens were updated to better match the app’s brand palette.&lt;br&gt;
Quick-action buttons were added to rider and driver hero cards, and a new logo was added to the homepage.&lt;br&gt;
These changes help the app feel more polished and easier to navigate.&lt;/p&gt;

&lt;h1&gt;
  
  
  Before deployment, we plan on:
&lt;/h1&gt;

&lt;p&gt;Testing push notifications across more devices&lt;br&gt;
Improving the full rider-driver matching flow&lt;br&gt;
In app chat functionality&lt;br&gt;
Connecting payment status more closely with ride status&lt;br&gt;
Continuing to clean up time and schedule logic&lt;br&gt;
Improving reliability of ride history and scheduled ride screens&lt;/p&gt;

</description>
      <category>devjournal</category>
      <category>mobile</category>
      <category>reactnative</category>
      <category>ui</category>
    </item>
    <item>
      <title>Stripe Payment and Processing</title>
      <dc:creator>Nathan Sisay</dc:creator>
      <pubDate>Mon, 18 May 2026 05:37:29 +0000</pubDate>
      <link>https://dev.to/guslift/stripe-payment-and-processing-3146</link>
      <guid>https://dev.to/guslift/stripe-payment-and-processing-3146</guid>
      <description>&lt;p&gt;Since GusLift is a ride-sharing platform, payments are an important part of the user experience. Riders need a simple and secure way to complete payment, while the backend needs a reliable way to track payment status for each ride. To begin this process, we integrated Stripe Checkout into the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing Stripe Checkout
&lt;/h2&gt;

&lt;p&gt;One of the biggest goals so far was to create a payment flow that could be tested safely during development.&lt;br&gt;
To do this, we added a backend route that creates a Stripe Checkout Session. When the mobile app requests a payment, the backend communicates with Stripe and receives a hosted checkout URL.&lt;br&gt;
The user can then open that Stripe-hosted page and complete the payment using a test card.&lt;br&gt;
This approach is useful because Stripe handles the secure payment form, card validation, and checkout experience. GusLift does not need to directly collect or store sensitive card information, which makes the implementation safer and easier to maintain.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8291hw526c6v7vajagmu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8291hw526c6v7vajagmu.png" alt=" " width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Payments to Rides
&lt;/h2&gt;

&lt;p&gt;Another important part of the payment system was making sure payments could be connected back to a specific ride.&lt;br&gt;
When a checkout session is created, GusLift can include ride information such as:&lt;br&gt;
-Ride ID&lt;br&gt;
-Rider ID&lt;br&gt;
-Ride label&lt;br&gt;
-Customer email&lt;br&gt;
-Payment amount&lt;/p&gt;

&lt;p&gt;This information allows the backend to keep track of which payment belongs to which ride.&lt;br&gt;
We also added support for storing payment records in a RidePayments table. This table keeps track of information such as the Stripe checkout session ID, payment intent ID, amount, currency, checkout URL, and payment status.&lt;br&gt;
This gives the app a clearer record of what happened during the payment process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Payment Status
&lt;/h2&gt;

&lt;p&gt;After a user finishes checkout, Stripe redirects them to a success page. GusLift then retrieves the checkout session details from Stripe and updates the payment record.&lt;br&gt;
If the payment was completed successfully, the record can be marked as paid. If checkout is canceled, the payment can be marked as canceled instead.&lt;br&gt;
Tracking these states is important because ride status and payment status are separate things.&lt;br&gt;
For example, a ride could be accepted, but the payment may still be pending. Keeping those states separate makes the system easier to reason about as the app grows.&lt;br&gt;
Building the Mobile Checkout Screen&lt;br&gt;
On the mobile side, we added a Stripe Demo Checkout screen.&lt;br&gt;
This screen checks whether the backend payment configuration is ready before allowing the user to continue. If Stripe is configured correctly, the user can tap a button to launch checkout.&lt;br&gt;
For the current development version, the checkout uses a fixed demo amount of $5.00.&lt;br&gt;
The mobile screen also supports passing ride-related information into the checkout flow, which helps prepare the app for real ride payments later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Stripe Matters
&lt;/h2&gt;

&lt;p&gt;Stripe is useful for GusLift because it allows us to build payment processing without needing to manage sensitive payment details ourselves.&lt;br&gt;
Instead of building a custom card form from scratch, we can use Stripe’s hosted checkout flow. This improves security, reduces complexity, and lets the team focus more on the ride-sharing experience.&lt;br&gt;
It also gives us a strong foundation for future payment features, such as authorizing payment when a rider confirms a driver and capturing payment after the ride is completed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some of the next milestones include:
&lt;/h2&gt;

&lt;p&gt;Connecting payment more directly to confirmed rides&lt;br&gt;
Improving success and cancel handling in the mobile app&lt;br&gt;
Adding webhook support for more reliable payment updates&lt;br&gt;
Moving from simple checkout payments toward authorization and capture&lt;br&gt;
Testing the full ride flow from request to completed payment&lt;/p&gt;

&lt;p&gt;By integrating Stripe Checkout, adding backend payment routes, and storing payment records, the app now has a basic but functional payment flow. While there is still more work to do before payments are production-ready, this is an important step toward making GusLift feel like a complete ride-sharing platform.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Trust Layer: Ratings in GusLift (Current State)</title>
      <dc:creator>Kofi Amo-Antwi</dc:creator>
      <pubDate>Tue, 12 May 2026 17:45:28 +0000</pubDate>
      <link>https://dev.to/guslift/building-a-trust-layer-ratings-in-guslift-current-state-d0i</link>
      <guid>https://dev.to/guslift/building-a-trust-layer-ratings-in-guslift-current-state-d0i</guid>
      <description>&lt;p&gt;GusLift is a campus rideshare app for students at our university. After shipping ride history and profile photos, the next obvious gap was trust: before someone gets in a car (or offers a seat), they want a signal that the other person has shown up and behaved well on past trips.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data model:&lt;/strong&gt; one row per person, per ride&lt;br&gt;
Ratings live in Supabase in a ratings table keyed by the ride and who is giving the rating. Conceptually each row stores: which ride it was, who rated (from_user_id), who was rated (to_user_id), and an integer score from 1 to 5.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fii9o4n3d5ke1vuwsx5o1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fii9o4n3d5ke1vuwsx5o1.png" alt=" " width="538" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the API side, POST /api/ratings validates that payload and upserts on conflict so a user can adjust their score for the same ride without creating duplicates. GET /api/ratings?user_id=… aggregates every rating received by that user and returns a rounded average plus a count, which is what powers the driver’s headline number on the home screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ride history as the glue&lt;/strong&gt;&lt;br&gt;
The ride history endpoint already loads completed trips, riders, drivers, and cars. It now also pulls all ratings for those ride IDs and, for the currently logged-in user, attaches my_rating: the score they already submitted for that trip, if any.&lt;/p&gt;

&lt;p&gt;That single field unlocks two different UX needs:&lt;/p&gt;

&lt;p&gt;Lists can show a small “you rated this 4/5” pill without a second round-trip per row.&lt;br&gt;
Home can ask “do you have any completed rides where you still have not rated?” by scanning history for the first ride with my_rating == null.&lt;br&gt;
So history stays the source of truth for “what happened,” and ratings are a thin join on top.&lt;/p&gt;

&lt;p&gt;Rider experience: celebrate, then ask&lt;br&gt;
On the rider side, the flow is deliberately emotional before it is transactional. When a ride completes, a full-screen “Ride completed” overlay gives a moment of closure. After it dismisses, a bottom toast slides in: driver name, five stars, optional skip, and a link to ride details.&lt;/p&gt;

&lt;p&gt;The toast is time-limited (progress bar + auto-dismiss), posts to POST /api/ratings on star tap, and shows a short thank-you state before sliding away. If the user skipped the prompt earlier, a best-effort check against ride history can still surface the toast for the oldest unrated completed trip so people are not stuck forever.&lt;/p&gt;

&lt;p&gt;Ride detail (from history or from the toast’s “view details” path) also includes an inline star control so a rating can be submitted or changed later, using the same API.&lt;/p&gt;

&lt;p&gt;Driver experience: reputation, not nagging (for now)&lt;br&gt;
Drivers see their aggregate rating and count on the driver home hero when the GET endpoint returns data. History and detail screens can show the driver’s own past rating for a trip where that applies.&lt;/p&gt;

&lt;p&gt;The backend is symmetric (any user can rate any counterparty on a ride), but the mobile prompts are currently centered on riders rating drivers. Reciprocal “rate your rider” flows are a natural follow-up now that POST and history enrichment are in place.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>database</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building Push Notifications in GusLift</title>
      <dc:creator>Abdul-Salam Zakaria</dc:creator>
      <pubDate>Tue, 12 May 2026 17:37:00 +0000</pubDate>
      <link>https://dev.to/guslift/building-push-notifications-in-guslift-iec</link>
      <guid>https://dev.to/guslift/building-push-notifications-in-guslift-iec</guid>
      <description>&lt;p&gt;GusLift connects student drivers with riders heading the same way. The matching itself happens over a WebSocket, which is fine when both people are staring at the app. The problem is that most of the time they aren't. Someone requests a ride, locks their phone, and the driver on the other side of campus has no idea anyone is waiting.&lt;/p&gt;

&lt;p&gt;This post walks through how push notifications were wired into GusLift across the three services that make up the product: the Expo mobile app, the Next.js backend on Cloudflare, and the matching worker (a Durable Object on Cloudflare Workers).&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;There are exactly two moments where a push notification meaningfully changes user behavior:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A driver picks a rider from the waiting list. The rider needs to know to open the app and accept.&lt;/li&gt;
&lt;li&gt;The rider accepts. The driver needs to know the ride is locked in.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else, like seat counts updating or another driver joining the slot, is noise. Sending push for those would train users to ignore the notifications they actually need. So the scope was deliberately narrow: two event types, both tied to a confirmed action on the other side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture in one diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  mobile (Expo)                backend (Next.js)              matching-worker (DO)
  -------------                ----------------               --------------------
  registerCurrentUser     --&amp;gt;  POST /api/notifications/token
  PushToken()                  upsert into PushTokens
                                                              MatchingRoom event:
                                                                rider reserved
                                                                  or match accepted
                                                              --&amp;gt;  sendMatchPush
                                                                   Notification()
                                                                   reads PushTokens
                                                                   POST exp.host
  Notifications handler   &amp;lt;--  (Expo push service)            &amp;lt;--
  routes user to screen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mobile app owns token acquisition. The backend owns token storage. The matching worker owns dispatch. None of them know about the other two beyond a shared Supabase table and a stable contract on the wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  The storage layer
&lt;/h2&gt;

&lt;p&gt;The whole feature hinges on one table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"PushTokens"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"User"&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;without&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;without&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few choices worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;token&lt;/code&gt; is unique, not &lt;code&gt;(user_id, token)&lt;/code&gt;. The same physical device could in theory be reused across user accounts (think shared family phone, or a dev account on a personal device), and we want the latest user to claim it. Upserts use &lt;code&gt;onConflict: "token"&lt;/code&gt; so the row gets reassigned cleanly instead of duplicating.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;is_active&lt;/code&gt; is a boolean rather than deleting rows. When Expo reports &lt;code&gt;DeviceNotRegistered&lt;/code&gt; we mark the row inactive. This keeps history useful for debugging "why didn't I get a push" complaints without leaking junk into the active set.&lt;/li&gt;
&lt;li&gt;A small &lt;code&gt;set_push_tokens_updated_at&lt;/code&gt; trigger keeps &lt;code&gt;updated_at&lt;/code&gt; honest on every write. Push tokens rotate, especially on iOS, so the freshness of a token row is something we look at when triaging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Indexes are on &lt;code&gt;user_id&lt;/code&gt; and on &lt;code&gt;(user_id, is_active)&lt;/code&gt;. The hot read path is always "give me the active tokens for this one user," which the composite index covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registering a token from the mobile app
&lt;/h2&gt;

&lt;p&gt;The mobile side lives in &lt;code&gt;mobile/lib/pushNotifications.js&lt;/code&gt;. It runs on app start and also whenever the app comes back to the foreground:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;registerCurrentUserPushToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;registerCurrentUserPushToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "on resume" call matters more than it looks. Tokens can be revoked or rotated while the app is backgrounded (an OS update, a permission change, a reinstall), and Expo will hand back a different value next time you ask. Re-registering on foreground is the cheapest way to stay correct.&lt;/p&gt;

&lt;p&gt;The registration function itself is deliberately defensive. It walks through these steps and bails out loudly at any point that fails:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Skip on web entirely. There is no Expo push token there.&lt;/li&gt;
&lt;li&gt;Look up the user in &lt;code&gt;AsyncStorage&lt;/code&gt;. No stored &lt;code&gt;@user&lt;/code&gt;, no registration, since we'd have nowhere to attribute the token.&lt;/li&gt;
&lt;li&gt;On Android, create the &lt;code&gt;default&lt;/code&gt; notification channel with &lt;code&gt;MAX&lt;/code&gt; importance. Without this, notifications arrive but never make a sound or appear as a heads-up.&lt;/li&gt;
&lt;li&gt;Ask for permissions, requesting them if not already granted.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;getDevicePushTokenAsync&lt;/code&gt; for logging, then &lt;code&gt;getExpoPushTokenAsync&lt;/code&gt; with the EAS &lt;code&gt;projectId&lt;/code&gt; from &lt;code&gt;Constants.expoConfig.extra.eas&lt;/code&gt;. The Expo token is what the backend actually stores.&lt;/li&gt;
&lt;li&gt;POST it to &lt;code&gt;${BACKEND_URL}/api/notifications/token&lt;/code&gt; with an &lt;code&gt;x-user-id&lt;/code&gt; header.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every step logs a &lt;code&gt;[push] ...&lt;/code&gt; line. That has paid for itself many times over. When a user reports "I never got the notification," the first question is always "what does logcat say at app start," and the answer comes from these breadcrumbs.&lt;/p&gt;

&lt;p&gt;Sign-out goes through &lt;code&gt;deactivateCurrentUserPushToken&lt;/code&gt;, which calls &lt;code&gt;DELETE&lt;/code&gt; on the same endpoint. The server marks the row inactive rather than deleting it. If the device immediately signs back in we re-activate by upsert, no row churn.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend endpoint
&lt;/h2&gt;

&lt;p&gt;The token route is one file: &lt;code&gt;backend/app/api/notifications/token/route.ts&lt;/code&gt;. It exposes &lt;code&gt;POST&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The auth story is intentionally simple. The handler accepts a user id in three ways, in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fromHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-user-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fromHeader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fromHeader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bearer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseAuthHeaderUserId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;bearer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;bodyUserId&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// fall back to Supabase auth.getUser(bearer)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;x-user-id&lt;/code&gt; is what the mobile app sends today because we already have a verified Google user id in &lt;code&gt;AsyncStorage&lt;/code&gt; after login. The &lt;code&gt;Authorization: Bearer ...&lt;/code&gt; path is there for when we move the rest of the API behind Supabase JWTs. Keeping both paths in one helper means we can flip the flag on auth without touching the notifications surface.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;POST&lt;/code&gt; body looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ExponentPushToken[...]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ios"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler normalizes both fields (trim, lowercase platform), then upserts on &lt;code&gt;token&lt;/code&gt;. That's the entire write path. The service role key lives in &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt;, since this endpoint needs to write across users without going through RLS.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DELETE&lt;/code&gt; is almost the same shape. If the body includes a specific token, only that row gets deactivated. If it doesn't, every active token for that user gets flipped off. The "deactivate all" variant is what runs on sign-out, so a shared device doesn't keep buzzing the previous user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dispatching from the matching worker
&lt;/h2&gt;

&lt;p&gt;The matching worker is a Cloudflare Durable Object. Each ride slot (location + day + start_time) is its own room, and inside that room there's a small state machine driving who is waiting, who is reserved, and who is confirmed. The push dispatch is hooked into exactly two transitions in &lt;code&gt;MatchingRoom.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldSendPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;driver_selected_rider&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sendMatchPushNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;recipientUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;driver_selected_rider&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;riderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;driverId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldSendPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rider_confirmed_match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sendMatchPushNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;recipientUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rider_confirmed_match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;riderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;driverId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;rideId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ride&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First, the call is fire and forget (&lt;code&gt;void&lt;/code&gt;). The state machine should never wait on Expo, and a failed push should never break a match. Second, &lt;code&gt;shouldSendPush&lt;/code&gt; gates every call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why dedupe is necessary
&lt;/h3&gt;

&lt;p&gt;Durable Objects can replay events. State recovery, client reconnects, even a rider rapidly tapping "accept" can cause the same logical transition to fire twice. Without dedupe, the user gets two identical notifications back-to-back, which feels broken even though it isn't.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;shouldSendPush&lt;/code&gt; keeps an in-memory map keyed by &lt;code&gt;${slotKey}:${riderId}:${driverId}:${eventType}:${bucket}&lt;/code&gt;, where &lt;code&gt;bucket = floor(now / 30s)&lt;/code&gt;. Anything inside the same 30-second window for the same pair and event type is dropped. The map self-prunes anything older than 2 minutes, so it doesn't grow unbounded.&lt;/p&gt;

&lt;p&gt;This lives in the DO instance memory rather than Supabase. Cross-instance dedupe isn't needed because each slot only has one DO, and that DO is the only sender for the slot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Talking to Expo
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pushNotifications.ts&lt;/code&gt; in the worker is the actual sender. It does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch active tokens for the recipient out of &lt;code&gt;PushTokens&lt;/code&gt;. If a user has multiple devices, all of them get the message.&lt;/li&gt;
&lt;li&gt;Build a message per token and POST the array to &lt;code&gt;https://exp.host/--/api/v2/push/send&lt;/code&gt;. The payload sets &lt;code&gt;sound: "default"&lt;/code&gt;, a short human title and body, and stuffs the event metadata into &lt;code&gt;data&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Walk the response tickets. Any token that comes back with &lt;code&gt;DeviceNotRegistered&lt;/code&gt; gets bulk-updated to &lt;code&gt;is_active = false&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cleanup step is the part that's easy to forget. Without it, a user who reinstalls the app accumulates stale token rows, every push attempt eats a slot in the array sent to Expo, and you get rate-limited for ghosts. Treating &lt;code&gt;DeviceNotRegistered&lt;/code&gt; as a hint to deactivate keeps the active set healthy with zero ops work.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;data&lt;/code&gt; payload is the contract with the mobile app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// "driver_selected_rider" | "rider_confirmed_match"&lt;/span&gt;
  &lt;span class="nx"&gt;rider_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;riderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;driver_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;driverId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ride_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rideId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// present only on rider_confirmed_match&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Deep-linking from a tapped notification
&lt;/h2&gt;

&lt;p&gt;A push that just makes a sound is half a feature. The reason the data payload includes &lt;code&gt;type&lt;/code&gt; is so the mobile app can route the user to the correct screen when they tap the notification, instead of dropping them on the home screen.&lt;/p&gt;

&lt;p&gt;That listener lives in &lt;code&gt;mobile/app/_layout.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addNotificationResponseReceivedListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;driver_selected_rider&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/rider/ScheduledRidesRider&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rider_confirmed_match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/driver/ScheduledRidesDriver&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;setNotificationHandler&lt;/code&gt; above it controls in-app behavior. We show the banner and the list entry, but don't play a sound or set a badge while the app is foregrounded. The reasoning is that if the user is already in the app, the WebSocket has already delivered the same information through the UI, and an extra sound on top of that is annoying.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we fixed push notifications
&lt;/h2&gt;

&lt;p&gt;Everything above describes the design as if it landed clean. It didn't. The first end-to-end test had token registration logs that looked perfect, no notifications arriving on the Android device, and a matching flow that broke after a successful match (the rides screen rendered empty). What follows is the actual debug trail.&lt;/p&gt;

&lt;h3&gt;
  
  
  The five things that were broken
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Wrong EAS project ownership.&lt;/strong&gt; The Expo project ID baked into &lt;code&gt;app.json&lt;/code&gt; belonged to an account I no longer had access to. &lt;code&gt;npx eas credentials&lt;/code&gt; returned &lt;code&gt;Entity not authorized&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fix: removed the old &lt;code&gt;extra.eas.projectId&lt;/code&gt;, ran &lt;code&gt;npx eas init&lt;/code&gt; to mint a fresh one under my account, rebuilt the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Missing FCM credentials on the new Expo project.&lt;/strong&gt; Expo can issue an &lt;code&gt;ExponentPushToken[...]&lt;/code&gt; without FCM credentials, so registration looked fine, but &lt;code&gt;https://expo.dev/notifications&lt;/code&gt; returned &lt;code&gt;InvalidCredentials: Unable to retrieve the FCM server key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fix: in Firebase Console under Project Settings, Service accounts, Generate new private key (not &lt;code&gt;google-services.json&lt;/code&gt;, that's a different file). Uploaded it via &lt;code&gt;npx eas credentials&lt;/code&gt;, Android, Google Service Account, FCM V1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Stale tokens from the old project still in the DB.&lt;/strong&gt; After re-creating the project, the old &lt;code&gt;ExponentPushToken[...]&lt;/code&gt; rows in &lt;code&gt;PushTokens&lt;/code&gt; were still &lt;code&gt;is_active = true&lt;/code&gt;. Mixing them with the new token in a single Expo batch made the whole send return 400.&lt;/p&gt;

&lt;p&gt;Fix: &lt;code&gt;delete from "PushTokens" where user_id = '...';&lt;/code&gt; then re-open the app to register a fresh token under the new project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Backend swallowed Expo's error reason.&lt;/strong&gt; &lt;code&gt;pushNotifications.ts&lt;/code&gt; only logged the status code, so 400s were opaque.&lt;/p&gt;

&lt;p&gt;Fix: added the response body and the token list to the error log so future push failures self-explain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Notification tap went to the wrong screen.&lt;/strong&gt; &lt;code&gt;_layout.js&lt;/code&gt; routed &lt;code&gt;driver_selected_rider&lt;/code&gt; to &lt;code&gt;ScheduledRidesRider&lt;/code&gt; (upcoming rides), not the accept/reject card.&lt;/p&gt;

&lt;p&gt;Fix: routed to &lt;code&gt;/rider/AvailableDrivers&lt;/code&gt; with &lt;code&gt;driverId&lt;/code&gt; from the push payload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Tap stacked a duplicate AvailableDrivers screen.&lt;/strong&gt; When the rider was already on &lt;code&gt;AvailableDrivers&lt;/code&gt; (because the in-app &lt;code&gt;match_request&lt;/code&gt; had already routed them there with full driver details), tapping the notification called &lt;code&gt;router.push&lt;/code&gt; again, pushing a second copy on top, hydrated only with &lt;code&gt;driverId&lt;/code&gt;, so it showed "Unknown Driver".&lt;/p&gt;

&lt;p&gt;Fix: added a &lt;code&gt;pathnameRef&lt;/code&gt; guard, skip the navigation if already on the target screen. Same guard for the driver-side &lt;code&gt;rider_confirmed_match&lt;/code&gt; notif.&lt;/p&gt;

&lt;h3&gt;
  
  
  Side bugs found and fixed along the way
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Empty upcoming-rides screen after accept.&lt;/strong&gt; Worker wrote &lt;code&gt;ride_date&lt;/code&gt; in UTC; backend queried in local time. Aligned the writer to use local-date components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rider showing 3x on driver's screen.&lt;/strong&gt; Rider's WS reconnects re-sent &lt;code&gt;rider_request&lt;/code&gt;, and &lt;code&gt;handleRiderRequest&lt;/code&gt; had no dedupe. Now ignored if the rider is already waiting or in a pending match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;INVALID_STATE_ERR&lt;/code&gt; from &lt;code&gt;MatchingContext.send&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;?.&lt;/code&gt; only guarded null, not &lt;code&gt;CONNECTING&lt;/code&gt;/&lt;code&gt;CLOSING&lt;/code&gt; &lt;code&gt;readyState&lt;/code&gt;. Now checks &lt;code&gt;readyState === 1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The actual checklist for end-to-end push
&lt;/h3&gt;

&lt;p&gt;Working backwards from the bug list, the requirements turned out to be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Own the Expo project (account access).&lt;/li&gt;
&lt;li&gt;Upload an FCM V1 service-account key to it (so Expo can deliver to Android).&lt;/li&gt;
&lt;li&gt;Have a fresh, valid token registered in your DB.&lt;/li&gt;
&lt;li&gt;Have the server actually call the send (it was, the matching flow does).&lt;/li&gt;
&lt;li&gt;Route the tap somewhere useful, without stacking duplicate screens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Items 1 and 2 are environmental and have nothing to do with the code. Items 3 through 5 are the ones the codebase has to keep honest forever. Most of the time when push "stops working" for a user later, the cause will be one of those three.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost, what it bought
&lt;/h2&gt;

&lt;p&gt;In code, this feature is small. One SQL file, one Next.js route, one mobile lib, one worker module, and a handful of call sites in the existing matching room. The whole thing is a few hundred lines.&lt;/p&gt;

&lt;p&gt;What it bought is the ability to stop telling users "keep the app open while you wait." That single sentence was the biggest source of friction in early testing, and it didn't go away until tokens, dispatch, and dedupe were all in place.&lt;/p&gt;

&lt;p&gt;A few things would be worth picking up later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server-side dedupe at the Supabase layer would let us survive a DO restart without re-sending. Today the 30-second bucket protects the common cases but isn't bulletproof across instance churn.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;Authorization&lt;/code&gt; header path in the token route is wired up but not exercised. Moving the mobile client onto Supabase JWTs would let us drop &lt;code&gt;x-user-id&lt;/code&gt; and the trust assumption that goes with it.&lt;/li&gt;
&lt;li&gt;Right now the notification title and body strings are hardcoded in the worker. A small templating layer would make it cheaper to localize and to A/B test the copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those block shipping. The current setup has been quietly delivering both event types reliably, deactivating dead tokens on its own, and routing taps to the correct screen, which is exactly the bar I wanted before writing about it.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>nextjs</category>
      <category>serverless</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building Ride History &amp; Profile Customization in GusLift</title>
      <dc:creator>Kofi Amo-Antwi</dc:creator>
      <pubDate>Tue, 05 May 2026 18:23:34 +0000</pubDate>
      <link>https://dev.to/guslift/building-ride-history-profile-customization-in-guslift-3lc9</link>
      <guid>https://dev.to/guslift/building-ride-history-profile-customization-in-guslift-3lc9</guid>
      <description>&lt;p&gt;Ride History: Tracking Every Completed Trip&lt;br&gt;
The idea behind Ride History is straightforward: after a driver marks a ride as complete, both the rider and the driver should be able to look back at all their past trips. Not just today's rides, but their entire account history.&lt;/p&gt;

&lt;p&gt;On the backend, the API queries Supabase for every ride tied to the user's Google ID, filtered to only completed rides and ordered newest-first:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9yadxjybxm6u524mg4df.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9yadxjybxm6u524mg4df.png" alt=" " width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's no date filter and no row limit -- it returns every completed ride the account has ever taken. On the mobile side, each ride renders as a card showing the date, route, pickup time, and the matched rider or driver's name. Users can also dismiss individual entries or clear the whole list, which sets a driver_hidden or rider_hidden flag without actually deleting data.&lt;/p&gt;

&lt;p&gt;One challenge we ran into was understanding the ride lifecycle. Rides are created with status: "accepted" when a match is confirmed through our real-time matching worker. They only flip to "completed" when the driver explicitly taps "Complete ride" on their dashboard. This means the history screen is intentionally limited to trips both parties actually finished -- not just ones that were matched.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw14oxm66sk13nab5n8py.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw14oxm66sk13nab5n8py.png" alt=" " width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Profile Image Picker&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flwxqwk4zbgxmmumidd44.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flwxqwk4zbgxmmumidd44.png" alt=" " width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We also built a reusable PhotoPicker component that lets both riders and drivers set a profile photo during setup. It uses Expo's ImagePicker API to request gallery permissions, open the device photo library, and crop to a 1:1 square:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwjxwk6cgent38mpwivz3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwjxwk6cgent38mpwivz3.png" alt=" " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The selected image is sent to the backend as part of a FormData payload during registration, where it gets uploaded to Supabase Storage and the public URL is saved to the user's profile. This was trickier than expected because React Native and web handle file blobs differently -- on web we can pass the raw File object, but on native we pass a { uri, name, type } descriptor that fetch knows how to stream as multipart form data.&lt;/p&gt;

&lt;p&gt;The profile photo then shows up across the app: in the rider's home screen header, in the driver's rider list, and anywhere a user's identity is displayed.&lt;/p&gt;

&lt;p&gt;Author: Kofi Amo-Antwi&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>mobile</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Week 6: How GusLift Matches Rides in Real Time</title>
      <dc:creator>Abdul-Salam Zakaria</dc:creator>
      <pubDate>Sun, 15 Mar 2026 13:41:03 +0000</pubDate>
      <link>https://dev.to/guslift/week-6-how-guslift-matches-rides-in-real-time-41pl</link>
      <guid>https://dev.to/guslift/week-6-how-guslift-matches-rides-in-real-time-41pl</guid>
      <description>&lt;p&gt;Every school morning, a few drivers leave their dorms for campus, and a bunch of riders need seats. The core problem: match them fast, in the right direction, before the window closes.&lt;/p&gt;

&lt;p&gt;Here's how the matching engine actually works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Matching Room Abstraction
&lt;/h2&gt;

&lt;p&gt;We don't run one global pool. We partition by location, day, and departure time. A room key looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Westie:mon:08:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string becomes the identity of a Cloudflare Durable Object(DO), a live, in-memory process that owns all real-time state for that slot. Everyone heading from Westie at 8am Monday shares one room. Leaving at 10:30? Different room entirely. This keeps each instance small and focused. The matching room doesn't know or care about any other departure window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Into the Right Room
&lt;/h2&gt;

&lt;p&gt;When a user opens the app, a Cloudflare Worker handles the request. It authenticates, resolves the user's schedule, generates the slot key, and forwards the WebSocket connection to the right DO. The Worker is stateless, purely a router. All the interesting state lives in the object it points to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the DO Tracks
&lt;/h2&gt;

&lt;p&gt;Four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;drivers&lt;/code&gt; — map of driver ID → seats remaining&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;riders_waiting&lt;/code&gt; — FIFO queue of riders requesting a ride&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pending_matches&lt;/code&gt; — riders a driver has selected but who haven't confirmed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;connections&lt;/code&gt; — live WebSocket handles, one per user&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every state change broadcasts to all connected clients. The frontend is a mirror of what the DO holds, nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Flow
&lt;/h2&gt;

&lt;p&gt;Driver goes online → sends &lt;code&gt;driver_online&lt;/code&gt; with seat count → registered in the room, broadcast to everyone. Rider wants a seat → sends &lt;code&gt;rider_request&lt;/code&gt; → added to the queue.&lt;/p&gt;

&lt;p&gt;Driver picks someone. That rider moves out of &lt;code&gt;riders_waiting&lt;/code&gt; into &lt;code&gt;pending_matches&lt;/code&gt; and gets a &lt;code&gt;match_request&lt;/code&gt; pushed to their socket. They have 30 seconds to accept. No response → back to the queue. They accept → seat decrements, ride written to Postgres, room updates for everyone.&lt;/p&gt;

&lt;p&gt;The part that could get hairy is concurrent events — two drivers selecting the same rider at the same time. The DO's single-threaded execution model handles that without any extra locking. One message at a time, in order. It's one of the legitimately nice properties of this architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Durable Objects
&lt;/h2&gt;

&lt;p&gt;Serverless and WebSockets are a bad pairing by default. Serverless assumes requests are stateless and short-lived; a WebSocket is neither. DOs give you a persistent, single-threaded process with in-memory state that survives across events. For a campus app where load is concentrated in a 20-minute morning window, you get rooms that spin up when needed and hibernate when not. No idle infrastructure, no connection hand-off problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Left
&lt;/h2&gt;

&lt;p&gt;A few open problems the current design doesn't address: drivers who cancel after a match is accepted, rooms that never get cleaned up after their departure window passes, and rate limiting on socket events. None of these tasks is difficult in principle. They just weren't the initial challenges.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>infrastructure</category>
      <category>backenddevelopment</category>
      <category>database</category>
    </item>
    <item>
      <title>Week 5: Improving Session Persistence and Planning Cross-Platform Support</title>
      <dc:creator>SundeepShrestha</dc:creator>
      <pubDate>Sat, 07 Mar 2026 00:39:33 +0000</pubDate>
      <link>https://dev.to/guslift/week-5-improving-session-persistence-and-planning-cross-platform-support-9ei</link>
      <guid>https://dev.to/guslift/week-5-improving-session-persistence-and-planning-cross-platform-support-9ei</guid>
      <description>&lt;p&gt;Last week, we introduced GusLift and focused on building the authentication system that allows users to log in using their Google accounts. This week, we continued working on improving how user sessions are handled within the app and started planning how the application will support multiple platforms.&lt;/p&gt;

&lt;p&gt;Our main focus was improving session persistence and thinking about how the platform will eventually run across iOS, Android, and web environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Improving User Session Management
&lt;/h2&gt;

&lt;p&gt;One challenge that often appears in mobile applications is how to handle user sessions after authentication. Ideally, users should not have to log in every time they open the app, but at the same time, sessions should not remain active indefinitely for security reasons.&lt;/p&gt;

&lt;p&gt;To address this, we implemented Async Storage to store user session data locally on the device.&lt;/p&gt;

&lt;p&gt;When a user successfully signs in using Google authentication, key information about the session is saved locally. This allows the application to recognize returning users and automatically keep them logged in.&lt;/p&gt;

&lt;p&gt;Currently, sessions are configured to remain active for approximately seven days before requiring the user to authenticate again.&lt;/p&gt;

&lt;p&gt;This approach improves usability while still maintaining a reasonable level of account security.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Async Storage Matters
&lt;/h2&gt;

&lt;p&gt;Async Storage allows the app to persist small pieces of information locally on a user’s device.&lt;/p&gt;

&lt;p&gt;In the case of GusLift, it is used to store session-related information so that users can continue using the app without repeatedly signing in.&lt;/p&gt;

&lt;p&gt;Without this feature, every time a user closed and reopened the application, they would need to authenticate again, which would make the experience frustrating.&lt;/p&gt;

&lt;p&gt;By storing session data locally, the app can restore the user's session quickly when the application launches.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frymjuxmshebgb9ax9pkt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frymjuxmshebgb9ax9pkt.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Planning Cross-Platform Support
&lt;/h2&gt;

&lt;p&gt;Another goal for the GusLift project is ensuring that the application can run across multiple platforms.&lt;/p&gt;

&lt;p&gt;Since the project is being built using React Native with Expo, we have the flexibility to target several environments with a single codebase.&lt;/p&gt;

&lt;p&gt;Our long-term objective is to support:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iOS devices&lt;/li&gt;
&lt;li&gt;Android devices&lt;/li&gt;
&lt;li&gt;Web browsers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Supporting multiple platforms increases accessibility and allows users to interact with the platform in whichever way is most convenient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Cross-Platform Development Matters
&lt;/h2&gt;

&lt;p&gt;Building separate applications for each platform can significantly increase development time and maintenance complexity.&lt;/p&gt;

&lt;p&gt;Using React Native allows the team to reuse much of the same codebase across different environments, which simplifies development and makes future updates easier to manage.&lt;/p&gt;

&lt;p&gt;This approach also allows us to test features more quickly as the project continues to evolve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next Steps
&lt;/h2&gt;

&lt;p&gt;With authentication and session persistence in place, the next stage of development will focus on building the core functionality of the platform.&lt;/p&gt;

&lt;p&gt;Some of the next milestones include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Designing the ride request workflow&lt;/li&gt;
&lt;li&gt;Implementing driver availability&lt;/li&gt;
&lt;li&gt;Building the ride matching logic&lt;/li&gt;
&lt;li&gt;Improving the user interface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As the project progresses, we will continue documenting the development process and technical decisions in these weekly updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;The improvements made this week help create a smoother user experience by allowing users to remain logged in while also preparing the application for broader platform support.&lt;/p&gt;

&lt;p&gt;Although the project is still in the early stages, these foundational components will make it easier to build the core ride-sharing functionality in the coming weeks.&lt;/p&gt;

</description>
      <category>devjournal</category>
      <category>mobile</category>
      <category>security</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Week 4: Understanding what GusLift Is</title>
      <dc:creator>SundeepShrestha</dc:creator>
      <pubDate>Sat, 07 Mar 2026 00:09:31 +0000</pubDate>
      <link>https://dev.to/guslift/week-4-understanding-what-guslift-is-79d</link>
      <guid>https://dev.to/guslift/week-4-understanding-what-guslift-is-79d</guid>
      <description>&lt;p&gt;Many students at Universities or Colleges rely on friends, rideshare services, or campus transportation to get around. While services like Uber and Lyft exist, they are not always practical for short trips around campus or nearby areas.&lt;/p&gt;

&lt;p&gt;For example, a student might need a ride from their dorm to an academic building, a nearby apartment, or a grocery store. In situations like this, requesting a full rideshare service often feels unnecessary or expensive.&lt;/p&gt;

&lt;p&gt;GusLift started as an idea to solve this problem by creating a campus-focused ride-sharing platform designed &lt;strong&gt;specifically for students.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The goal is simple: &lt;strong&gt;Make it easier&lt;/strong&gt; for students to connect with others who are already driving somewhere nearby, while keeping the platform limited to trusted users within the college community.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Idea Behind GusLift
&lt;/h2&gt;

&lt;p&gt;Unlike traditional ride-sharing apps that are open to anyone, GusLift is designed to be restricted to verified users from the campus community.&lt;/p&gt;

&lt;p&gt;This approach helps create a safer and more trusted environment where students know they are interacting with other members of their school community.&lt;/p&gt;

&lt;p&gt;Instead of requesting rides from strangers, users can connect with other students who are already heading in the same direction.&lt;/p&gt;

&lt;p&gt;The long-term goal is to create a small, efficient transportation network within the campus ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5amc3yluehkszq6pj6uz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5amc3yluehkszq6pj6uz.png" alt=" " width="384" height="587"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7c7byz01mef2znevgwde.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7c7byz01mef2znevgwde.png" alt="Early concept for the GusLift interface." width="371" height="675"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Early concept for the GusLift interface.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Built So Far
&lt;/h2&gt;

&lt;p&gt;This week, we focused on setting up the authentication system for the application.&lt;/p&gt;

&lt;p&gt;Authentication is an important part of GusLift because the platform needs to ensure that only approved users can access the app.&lt;/p&gt;

&lt;p&gt;To accomplish this, we implemented Google Sign-In.&lt;/p&gt;

&lt;p&gt;This allows users to log into the application using their Google accounts instead of creating new usernames and passwords. Using Google authentication simplifies the login process while also providing a reliable identity verification system.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7h6ss8hec47zbteurqyi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7h6ss8hec47zbteurqyi.png" alt=" " width="800" height="247"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9sa31hlbl9l5y3fukve4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9sa31hlbl9l5y3fukve4.png" alt="Google authentication is used to verify users during login." width="800" height="179"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Google authentication is used to verify users during login.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping Users Logged In
&lt;/h2&gt;

&lt;p&gt;After implementing login functionality, the next step was making sure users do not have to sign in every time they open the app.&lt;/p&gt;

&lt;p&gt;To solve this, we implemented Async Storage, which allows the application to store small pieces of data directly on the user's device.&lt;/p&gt;

&lt;p&gt;Once a user logs in successfully, their session information is saved locally. This allows the app to recognize returning users and automatically keep them signed in.&lt;/p&gt;

&lt;p&gt;Currently, user sessions are stored for seven days, after which the user will be asked to authenticate again.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;await AsyncStorage.setItem(&lt;br&gt;
        "@user",&lt;br&gt;
        JSON.stringify({ ...data, savedAt: Date.now() })&lt;br&gt;
      );&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This approach improves usability while still maintaining basic security.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technologies Used
&lt;/h2&gt;

&lt;p&gt;The current version of GusLift is being built using the following tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React Native (Expo) for cross-platform mobile development&lt;/li&gt;
&lt;li&gt;Google Authentication for user login &lt;/li&gt;
&lt;li&gt;Async Storage for storing session information locally&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These tools allow us to rapidly prototype features while maintaining flexibility for future expansion.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;In the coming weeks, we plan to continue expanding the foundation of the app.&lt;/p&gt;

&lt;p&gt;Some of the next steps include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Improving how user sessions are handled within the application&lt;/li&gt;
&lt;li&gt;Expanding support across multiple platforms&lt;/li&gt;
&lt;li&gt;Beginning development of the core ride-sharing functionality&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One of the long-term goals for the project is to make GusLift available across iOS, Android, and web platforms, allowing users to access the system from different devices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;GusLift is still in the early stages of development, but the core authentication system now provides a starting point for building the rest of the platform.&lt;/p&gt;

&lt;p&gt;As development continues, the focus will shift toward building the features that actually enable users to request and offer rides.&lt;/p&gt;

&lt;p&gt;Future posts will document the progress of the project as we continue building and testing new components of the system.&lt;/p&gt;

</description>
      <category>devjournal</category>
      <category>sideprojects</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
