JACOB LURIE
Marco Polo, One Floor at a Time

Marco Polo, One Floor at a Time

Over-engineering an iOS app with geofencing, GPS altitude, and local SQLite because my brain won't remember a single number


There are a million and one daily frustrations I could sit down and try to engineer away, but the one that gets me every single time is embarrassingly simple… where the did I park my car?

I live in an apartment building with a 10-floor parking garage. At least once a week I confidently send the elevator to the wrong floor, step out, and immediately realize my car is nowhere in sight. What follows is a stairwell hike and aggressive key-fob-button mashing until I hear a distant beep echo off the concrete. It’s the world’s loudest game of Marco Polo.

In retaliation, I built a fully local iOS mobile app to solve it (sorry not sorry android). No accounts, no servers — just my phone helping me remember what my brain refuses to. Here’s what I learned.

The Premise

When solving this problem, I was able to divide the project into two distinct halves. The first was manual entry of the floor, the second being automatic detection.

In both cases, I would need to track user location and make a series of actions given designated areas that the user indicated were their home location(s).

The flow was pretty simple:

  1. When the user first downloads the app, they indicate where their home location is, optionally adding the number or names of floors for easier data entry.
  2. At some point in time, the user leaves their garage and the app begins geo-fencing the garage’s location.
  3. The user returns to that garage, which triggers a push notification to the user to set the floor they parked on.
  4. The user opens the app and enters the floor that they parked on.

Part 1: The simple implementation

For this app, I used React Native with Expo, using expo-location for geofencing, expo-notifications for local push notifications, and expo-sqlite with Drizzle ORM for fully on-device storage. No backend, no analytics — just SQLite and the time-honored QA strategy of “ship it and find out”


Setting up

The first thing the user does after granting quite a few permissions in onboarding (more to come on this later) is drop a pin on a map to mark their garage. They can optionally label it, pick a color, and pre-save floor names like P1 through P5 for quick entry later.

Adding a home location

Under the hood, each home is a row in SQLite with a latitude, longitude, and a geofence radius (defaulting to 100 meters):

export const homes = sqliteTable("homes", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  label: text("label"),
  color: text("color"),
  latitude: real("latitude").notNull(),
  longitude: real("longitude").notNull(),
  radius_m: real("radius_m").notNull(),
  updated_at: text("updated_at").notNull(),
});

Geofencing the garage

Once a home exists, the app registers a geofence region for it using expo-location and expo-task-manager. This runs entirely in the background — the app doesn’t need to be open (expo task manager is awesome).

const regions = homes.map((home) => ({
  identifier: `home-${home.id}`,
  latitude: home.latitude,
  longitude: home.longitude,
  radius: home.radius_m,
  notifyOnEnter: true,
  notifyOnExit: true,
}));

await Location.startGeofencingAsync(GEOFENCE_PARKING_TASK, regions);

When the OS fires a geofence event, a background task picks it up. One gotcha here: geofence events can be imprecise. The OS might fire an enter event when you’re still a block away. So the task grabs fresh GPS coordinates and double-checks the distance using Haversine before acting on it:

const currentLocation = await Location.getCurrentPositionAsync();
const distance = distanceM(
  home.latitude, home.longitude,
  currentLocation.coords.latitude, currentLocation.coords.longitude,
);

The notification

If the distance check passes and there’s no existing active parking for that garage, the task creates a new parking record with a RETURNED status and fires a local notification:

const parkingId = await insertParking({
  home_id: homeId,
  status: "RETURNED",
  created_at: new Date().toISOString(),
  altitude: currentLocation.coords.altitude,
  altitude_accuracy: currentLocation.coords.altitudeAccuracy,
});

sendDeviceNotification({
  title: "Welcome back!",
  body: "Click to enter the floor you parked on",
  data: { homeId },
});

The altitude data gets stashed here too — it’ll come in handy later for Part 2.


Entering the floor

Tapping the notification (or just opening the app) presents a modal with two options: tap one of the pre-saved floor buttons, or type in a custom value. Once saved, the parking flips from RETURNED to ACTIVE and the floor is synced to an iOS widget via widget extension storage so you can check it from your home screen without opening the app.

The widget itself was scaffolded with npx create-target and the @bacons/apple-targets Expo config plugin.

iOS widget showing parked floor

The full parking lifecycle is status-driven:

  • RETURNED — you just pulled in, floor unknown
  • ACTIVE — floor recorded, this is where your car is
  • LEFT — you drove away, parking archived, this row will never be updated again

When you leave the geofence, all ACTIVE parkings defensively flip to LEFT. When you come back, the cycle starts over.

export const parkings = sqliteTable("parkings", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  home_id: integer("home_id").notNull().references(() => homes.id),
  floor: text("floor"),
  note: text("note"),
  altitude: real("altitude"),
  altitude_accuracy: real("altitude_accuracy"),
  status: text("status").notNull().default("LEFT"),
  created_at: text("created_at").notNull(),
});
Home screen with active parking

That’s the whole loop. You park, you get a nudge, you tap a button, and the next time you’re wandering the garage you can just glance at the widget instead of waking up the surrounding two blocks of high-rises with a symphony of beeps.

Part 2: Predictions… or so I hope

Part 1 works great, but it still requires you to remember which floor you parked on in the moment. If you’re anything like me, you’ve already zoned out by the time you step out of the car. The dream is to skip the manual step entirely — have the app figure out the floor on its own.

The phone already knows its altitude and floors in a parking garage are at fixed heights. So in theory, if I can map altitude readings to floor labels, I can predict the floor automatically. In practice… GPS altitude is a mess. But let’s try anyway.


The geo audit

The first step was collecting data. When a new RETURNED parking is created and the feature is toggled on, the app kicks off a second background task — BACKGROUND_ALTITUDE_TASK — that starts streaming and persisting high-sample location updates to a geo_audit table:

export const geoAudit = sqliteTable("geo_audit", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  parking_id: integer("parking_id").notNull(),
  altitude: real("altitude"),
  altitude_accuracy: real("altitude_accuracy"),
  latitude: real("latitude"),
  longitude: real("longitude"),
  speed: real("speed"),
  timestamp: real("timestamp").notNull(),
  created_at: text("created_at").notNull(),
});

Every location callback dumps its payload into this table — altitude, accuracy, speed, coordinates, everything. It’s an audit log of raw sensor data, tied to the parking session it belongs to. The user can export this data from settings if they want to poke at it themselves.

One of the perks of using Drizzle ORM is that it ships with Drizzle Studio completely out of the box to browse and query tables directly. During development this was huge for sanity-checking audit data without writing throwaway queries or wiring up another debug screen.

Drizzle Studio showing geo audit data

Smoothing the noise

Raw GPS altitude is noisy. You can be standing still and watch the reading jump a couple meters between samples. So before the predictor touches any of it, the samples pass through a rolling moving average with a window size of 5:

smoothAltitudeBuffer(rawSamples, windowSize): SmoothedSample[]

This flattens out the jitter enough to get a usable signal. Not perfect — but good enough to distinguish between floors that are typically 3+ meters apart.


Knowing when you’ve parked

You can’t predict a floor while the user is still driving up the ramp. The predictor needs to know when the car has stopped. This is handled by a small state machine that classifies the parking state based on the smoothed samples:

unknownmovingsettlingparked

The transition to parked requires all of:

  • Altitude variance ≤ 1.2m² across the window
  • Delta between consecutive samples ≤ 0.8m
  • At least 5 consecutive stable samples
  • Window duration ≥ 10 seconds

Side note, expo sensor measurements are in meters, so we unfortunately have to endure the metric system for all of these calculations

Once all four conditions hold, the predictor is confident the car isn’t moving anymore and it’s safe to take a reading.

Another side note: My garage is small, so a 10-second window feels about right for knowing when I’ve actually parked. In a bigger garage or lot, you’d probably want to bump that up.


Building calibration

Predictions need a reference point — it needs to know what altitude each floor should be. This is built automatically from the user’s own history. Every time you manually enter a floor (Part 1), the app has a parking record with both a floor label and an altitude reading. Over time, this builds up a calibration map:

export const getCalibrationForHome = async (
  homeId: number,
): Promise<GarageCalibration | null> => {
  // Queries all parkings for this home with both floor AND altitude
  // Groups by floor label, averages the altitude per floor
  // Requires at least 2 samples per floor
};

The requirement of at least 8 samples, 3 of which coming from a different floor is arbitrary but intentional — a single reading could be an outlier. Once calibration exists, the predictor can start working. It’s not a perfect system, but it’s one that learns with the user’s behavior.


Making the prediction

When the state machine hits parked, the predictor computes the average smoothed altitude and checks it against calibration:

predictFloor(altitude, calibration, opts): FloorPrediction | null

It finds the nearest floor by altitude distance and assigns a confidence score:

  • ≤ 1.5m away from a known floor → confidence 1.0
  • ≥ 4.0m away from any floor → returns null (no guess is better than a bad guess)
  • In between → linear calculation

If the prediction succeeds, the parking flips straight from RETURNED to ACTIVE with the predicted floor, the widget syncs, and the user gets a notification saying “You parked on floor P3” — no manual entry needed.

If it fails (no calibration yet or low confidence), the app falls back to the Part 1 flow and asks them to enter it manually. That manual entry then feeds back into calibration for next time.


Keeping state alive

Background tasks on mobile are ephemeral — the OS can kill and restart them at will. So the altitude buffer and the active parking ID are persisted to MMKV (a fast key-value store that survives app restarts):

// Parking ID persisted across task restarts
storage.set("background_altitude_parking_id", parkingId);

// Altitude buffer persisted as JSON
storage.set("background_altitude_buffer", JSON.stringify(buffer));

When the task spins back up after an OS kill, it rehydrates from MMKV and picks up where it left off without losing any samples.


Testing it

To validate all of this without driving laps around my garage for hours (which I did anyway because it was fun), I needed a way to simulate altitude data and run the prediction pipeline against it. So I did what any reasonable developer would do in 2026 — I asked Cursor to build me a testing screen with a bunch of generated test data.

Within a couple of prompts it had scaffolded a dev-only screen that lets you punch in simulated altitude sequences, watch the state machine transition in real time, and see which floor the predictor lands on. It even generated a set of mock calibration data and edge-case test scenarios that I definitely would have been too lazy to write myself. Honestly, it probably saved me a full afternoon of tedious setup work so I could skip straight to the interesting part: watching my algorithm be wrong in creative new ways.

Dev testing screen for floor prediction

So… does it work?

This whole system is a bet on GPS altitude being consistent enough within a single garage. It doesn’t need to be accurate in absolute terms — I don’t care if my phone thinks floor P3 is at 47 meters or 52 meters above sea level. I just need it to report roughly the same number every time I park on P3.

Is it perfect? Not even close. Without a solid chunk of historical data to calibrate against, the predictions are more of a coin flip than a crystal ball. But even getting it right half the time means half as many stairwell adventures — and paired with the manual fallback from Part 1, I haven’t lost my car yet. I’ll call that a win.

Note: Level isn’t on the App Store yet — but it will be soon. Stay tuned.