You're hungry. You open the app. It picks one restaurant for you — not fifty — and explains why in a sentence you'd actually want to read. This case study is about giving the AI only the jobs it's good at (reading what you typed, writing the friendly explanation) and keeping every real decision somewhere the user can see. Plus the hardest design problem of single-pick recommenders: making the user feel heard even when the algorithm picks the same place again.
Existing apps treat hunger like a research project. Open Yelp, Google Maps, OpenTable — every one shows a 50-item list sorted by something opaque, and transfers the work of deciding from app to user. The actual job-to-be-done is "hand me a single confident pick, with the reasoning I can audit, in the time it takes me to put my shoes on." The product builds outward from that one act.
Showing more transfers cognitive load onto the user. A confident product picks one. The only way to do that without being wrong a lot is to have a reasoning system the user can interrogate when they disagree.
"We picked this because of X, Y, Z" beats "trending now." Every reason is sourced (rating · weather · your taste · distance · time · budget) and shown explicitly. Showing the work is what makes one-pick viable.
The same person at noon on Wednesday and 9pm on Friday wants different things. The recommender reads time, weather, learned price tier, recently-presented set, and a single user-chosen mood. The AI's job is to make that read feel human, not algorithmic.
This is the central Product AI question on every product I design: what is the model allowed to decide? In Bitez the answer is narrow on purpose. The LLM (Apple Foundation Models, on-device) gets two jobs — both about turning messy human language into something deterministic code can work with, or rendering deterministic code's output in human language. Everything that affects the actual recommendation runs through plain Swift.
DishIntent the recommender can use.[ MODEL READS · CODE DECIDES · MODEL NARRATES ]
Apple's on-device LLM has two narrow, high-leverage jobs in Bitez. Below are real frames from the live App Store build — the model isn't simulated, the screenshots aren't mockups. Each one shows exactly what the LLM contributes and what's still being decided by deterministic code underneath.
Enter password below to view
The calibration screen replays everything the user just said. The last row — "Locking in Spicy lover" — is the Foundation Models output. The user didn't pick "Spicy" from a list; they typed it (or "curry-something hearty", or "fish that doesn't smell"), and Apple's on-device LLM extracted it into a typed DishIntent the recommender knows how to use.
{ dish: "curry", cuisine: "Indian", isHearty: true }The model never picks the restaurant. It only converts messy phrasing into structured fields. A 110-entry keyword dictionary acts as the safety net — instant on common cases, the LLM only runs when the dictionary can't pin a cuisine. The result feels like the app understood you.
Enter password below to view
The line above the restaurant name — "Jestaz, Out of everything nearby, Nami Nori West Village is the one. Trust me." — is the model speaking. But it's not free-styling. It's reading the deterministic reasoning facts (rating, distance, walking time, mood, weather, recently-presented penalty, the budget-honesty fact below) and rewriting them in one sentence.
Underneath, the deterministic reasoning is still visible — every fact is sourced (BUDGET in this frame), strength-ranked, and auditable. The AI adds warmth; it doesn't replace the math. When a user changes their budget and the same place wins, the reasoning row labelled "Still your best match" is sticky — AI narration can't overwrite the honest fact underneath.
Two bounded jobs. Both about language — reading user phrasing, writing the explanation. Neither one ever decides anything that touches money, distance, or the actual pick.
The privacy promise and the personalization promise usually fight. A normal app says: "to recommend better, we need your behaviour." Then it ships that behaviour to a server, models you, and the more it knows the better it pretends to be. Bitez does the opposite: every "I'm going" tap is a private signal recorded in a counter on the device. After 5 commits the recommender knows what you actually pick — without anyone else knowing anything.
These are what you said. Treated as ground truth at cold start, because the app doesn't have anything else to go on yet.
likedCuisines: ["japanese": 5, "italian": 1]likedPriceLevels: [3: 4, 2: 1]recentVisits: [last 10 "I'm going" snapshots]seen: [skipped IDs for current session]These are what you actually picked. Live counters, never reset on app close, never synced anywhere. The user who declared $$ but keeps choosing $$$ gets a quiet $$$ bias from then on — without ever being told "you're a $$$ person now."
The math is deliberately bounded. Implicit signals are +bonus on top of the explicit framework, never a replacement for it. A user who declared "no Japanese" gets that respected even if they accidentally tapped one Japanese place — the explicit veto wins. Implicit cuisine gets up to +18 in Special mood; price-tier learning caps at +6 as a tiebreaker. The recommender can lean into what you do, but it can't override what you said.
What the app deliberately doesn't learn is just as important: no negative dampening on cuisines you skipped (that's a confirmation-bias loop where you stop seeing whole categories), no time-of-day × cuisine cross-tables (would overfit in 1.0 with 3-5 data points and pin a user to one mood-shape forever), no cross-user collaborative filtering at all (would require an account and a server — kills the privacy promise dead). Each one is queued as a 2.0 candidate when there's enough usage data to make the math honest.
Settings → Recent visits. Five most recent "I'm going" taps, tap to re-open in Google Maps or Apple Maps. The only visible surface of the learning data — everything else lives quietly inside the scorer. Users who want to see the model see it; users who don't, don't.
Reset everything in Settings is one tap. The "you're being learned about" anxiety is mitigated by making the data visibly cheap to throw away. The privacy promise isn't a clause buried in the EULA — it's an action button two screens deep.
Most restaurant-finder apps put a "Cafes" filter chip on the same list and call it a feature. That's wrong, because the success criteria genuinely differ — a dinner pick is rated on food quality, price tier, occasion fit; a cafe pick is rated on dwell-time signals (real seating, wifi-mention rate, noise level, room to spread out a laptop for two hours). Same word, "place to eat," very different shopping list. Bitez doesn't share the pool. The corner toggle is the visible surface of an architectural split.
*_restaurant types based on the user's saved cuisines (or generic restaurant when none)cafe, coffee_shop, tea_house primaries — Starbucks structurally cannot appear in Eat resultsPOPULARITY — surfaces the strong, well-reviewed optionscafe, coffee_shop, bakery, breakfast_restaurant, brunch_restaurant (cafes registered as breakfast spots are common in NYC)restaurant, bar, fast_food_restaurant primaries — actual restaurants don't sneak into Cafe results just because they serve coffeeDISTANCE — popularity drags in Magnolia/Levain 30 min away; the local independent that's actually walkable winsdineIn for real seating, editorialSummary scanned for "cozy / quiet / intimate," price tier INVERTED so $-$$ winsThe cache layer follows the split. Each fetch signature carries the context as its first axis ("dining" vs "cafe"), so the two pools never overlap or evict each other in the 8-signature LRU bundle. A user who toggles back and forth doesn't lose what they had — the cafe pool sleeps while the dining pool is active and vice-versa. The Settings sheet adapts: cuisines and dietary sections vanish when the context is Cafe (they describe meals, not work spots); "Cafe extras" appears in their place. Two contexts, two scorers, two Settings shapes — one navigation shell.
The design question that made this section worth writing: when should a product give the user a context switch vs a filter? A filter says "here's the same data, sliced differently." A context says "here's a different question, asked differently, answered differently." The threshold I held: if the WINNING signals change shape — not just weights, but which signals enter the math at all — then it's a context, not a filter. Dinner picks weight dish keywords; cafe picks weight dwell-time signals. Different shape. Two modes earned the split.
Bitez ships with its own design system, and every value in it was extracted straight from the shipped SwiftUI — so the source of truth is the app, and the design file can't drift away from what users actually see. It's structured the way a production library should be: raw primitives feed semantic tokens (with real Light/Dark modes), which feed a component set, which assemble into screen patterns. Screens are built only by composing component instances — never hand-drawn — so a token change ripples everywhere at once.
The literal palette from BBColor — brand reds, warm accents, ink darks, neutrals, alphas. No semantics yet.
surface / text / border / brand / status. Each aliases a primitive per mode, so Light and Dark are one switch.
23 tokens × 2 modesA 30-style Inter ramp grounded in real iOS sizes, plus a half-step spacing scale, radius, sizing and stroke tokens.
30 text styles · 42 number tokensButtons, chips, inputs, list rows, pills, the pick-screen blocks, the driving suite — composed into 9 full screens.
~30 components → 9 patternsThe point isn't the swatch grid — it's the discipline. Because the system was reverse-built from shipped code and screens are assembled only from instances, the design file is a faithful mirror of the product, not a hopeful sketch of it. One token edit re-themes every screen; one component fix propagates to every place it appears.
A user opens Settings, drags budget from $$ to $$$$, taps Save & refresh. The recommender re-ranks. The same restaurant wins. Mathematically the algorithm is right: a 4.5★ place 12 minutes away beats every $$$$ option in the user's radius on signal weight. Perceptually the app is broken. The user changed the input. They expect the output to change. When it doesn't, the algorithm has three seconds to defend itself out loud — or it loses the user.
This is the design problem most "smart" recommenders lose on. Yelp shows fifty options so the user feels in control. A single-pick product can't do that — and that's exactly the problem worth solving. This is the signature moment of the case study because it's the test of whether transparent reasoning is a real design discipline or just a buzzword.
Enter password below to view
The feedback came from a real tester. Direct quote, not paraphrased: "my friend keep saying after you change the budget range, it's giving you the same restaurant." One line of feedback, five layered design moves in response. The same place wins — but everything around the place changes loudly enough that the user understands the algorithm did run, and did hear them.
−25 score penalty against any restaurant the user has been shown in the recent past. Doesn't reshape the whole ranking, but gives the algorithm a small variety nudge so cold relaunches don't always land on yesterday's pick. Pure code, invisible to the user — but the user feels the variety.
ALGORITHM NUDGE
The recommendation didn't change. Everything around it did. Five visible design moves, ranging from a single inline phrase to a recommender-level score penalty, all in service of one outcome: the user understands the algorithm heard them, even when the result lands in the same place. The friend who'd called it broken never raised that complaint again.
[ ALGORITHM HOLDS · UI DEFENDS · USER UNDERSTANDS ]
Apple's Foundation Models run free on the user's phone. Google Places (New) — the data the recommender grounds every reasoning fact against — does not. A naive build hits the API on every screen open and burns the unit economics inside a week. Three small caching choices, each protecting the AI's accuracy without paying for it more than once a day.
Eight LRU pools by query signature — toggling Italian → Korean → Italian is free after the second visit.
Fetch the weekly schedule once, compute open / closed locally against the device clock. The pool stays valid 24 hours, not 30 minutes.
Severe-weather alerts at the user's coordinate enter the cache signature → automatic miss → live refetch right when local hours actually shift.
[ NAIVE: 1 API CALL / APP OPEN → STAGED: ~1 / DAY / USER ]
Iteration is the proof that the product is being validated by real people, not assumed from the inside. Each release below pairs an actual quote from a beta tester with the design / engineering response that shipped in answer. The point isn't speed — it's that the loop is closed: feedback in, fix out, ship, repeat. Nothing reverse-engineered for the case study.
openNow; keep unknown hours in the pool but bias them lower. Pool got smaller, pool got more honest. Three days later this got smarter: Stage 2 computes "open now" locally from the cached weekly schedule.
$$ badge, a "Still your top pick" confirmation chip when re-ranking legitimately picks the same place, and a -25 recently-presented penalty so cold relaunches don't always land on yesterday's pick.
markdownSafe extension, balance-check ** / _ counts at the AttributedString.markdown() entry, and sanitize AI-generated reasons before they reach the parser. Same crash had recurred — this stop-shipped it.
restaurant excluded by excludedPrimaryTypes only so cafes carrying "restaurant" as a SECONDARY tag still pass), a separate cafeScore branch (review count proxies capacity, dineIn confirms real seating, editorialSummary scanned for "quiet / cozy / intimate", price tier inverted so $-$$ wins), a separate cache pool, and DISTANCE ranking instead of POPULARITY — so the local independent beats Magnolia Bakery 30 minutes away. Mood and dish intent are disabled in Cafe context: they describe meals, not work spots.
restaurants = [] before the new fetch can race. No pool leakage between modes; failures now produce a true empty state with a clear "set your city" CTA, not fake picks dressed up as real ones.
minutesUntilClose() to the Restaurant model — runway computed locally from regularHours + device clock, no extra API call. Recommender now penalizes <30 min runway by -40 (effectively buries it), 30–60 by -22, 60–120 by -8 — in both Eat and Cafe modes. Pick card swaps the generic "Open now" for a concrete "Open until 11 PM", and when runway drops under an hour a red "Closing in 35 min" pill renders right under the meta row with a clock-with-exclamation glyph. Same data drives the score and the warning; no chance of disagreement.
(rating == 0 && reviewCount == 0) combo is now a hard reject at the provider layer. Plus a defensive distance gate that drops any computed result beyond 1.5× the requested search radius regardless of name or quality — Google occasionally ships places with miscoded lat/lng that pass the API's own radius check.
CFBundleDisplayName key in Info.plist had been silently corrupted — chat-debug content from an earlier session had embedded itself into the production app name. iOS injects that string verbatim into the system permission popup. Caught in real-device testing the day before App Store submission would have shipped — a guaranteed Apple review rejection. Fixed both Debug and Release configurations and replaced the location-permission description with the proper brand copy. Confidence in real-device dogfooding restored.
resolveCityToCoordinates method called by both surfaces. Three-layer geocode: raw input → 5-digit ZIP gets a ", USA" suffix (CLGeocoder's well-known blind spot) → 1.1-second pause + retry to recover from rate-limit throttling. Process-cache so repeated lookups of the same text never hit Apple. Plus a stricter rule on top: both surfaces now require the city field to contain at least one letter — pure-digit ZIPs are rejected at validation with an inline "Type Bayside instead of 11364" hint. Stops the failure before it can happen.
"terrible", "avoid", "would not recommend" ...) is scanned at every layer so the "what reviewers love" promise on the card title can't be falsified by a 1-star rant slipping through.
cafe, coffee_shop, and tea_house to dining mode's excludedPrimaryTypes — Google never returns a coffee-shop primary place when the user picked Eat, so the Starbucks-as-dinner case is structurally impossible. The reverse already excludes restaurant/bar/fast-food primaries from cafe mode. Bakery stays in Eat (Levain, Tartine, etc. are real food destinations). The two contexts are now truly disjoint pools — not just differently-scored views of the same pool.
UISceneSession.Role raw-string match, no CarPlay entitlement needed), AVAudioSession current route containing bluetoothA2DP / bluetoothHFP / carAudio, and CMMotionActivityManager reporting automotive at non-low confidence. Any one fires, the entire PickView swaps for DrivingPickView: pure-black background, 54pt restaurant name, 140pt Skip / I'm going buttons, friend line as the deterministic template (AI's 25-second timeout doesn't fit a stoplight). Auto-narrate via AVSpeechSynthesizer with .duckOthers + voicePrompt mode plays the friend line over CarPlay audio. Voice input via SFSpeechRecognizer (on-device when available) — tap mic, say "ramen", get a fresh pick. Settings → Diagnostics has a sticky Force driving view toggle for demoing without an actual car. False positives (Bluetooth headphones on a walk) get a "Use normal view" pill in the top-right.
VNClassifyImageRequest on-device: 60+ food-related identifier tokens (food, dish, pizza, ramen, salad, burger, ...) compute a max food confidence; anti-tokens (storefront, facade, sign, person, building) above 0.5 zero it out. Photos sort food-first so the burger photo floats above the storefront, but storefronts stay visible — the user wanted to "see the place," not just the food. Zero network round-trips for classification, zero permission prompts, ~25–80ms per image on A15+. The original website still lives as "View their store" at the bottom of the sheet — one tap further, no longer the front door.
Button + .disabled implementation that dimmed the whole card and looked broken on the very places that didn't need a toggle.
places-api.foursquare.com host, added Bearer-prefixed auth + the required X-Places-Api-Version date header, modeled the per-pick enrichment (foursquareNoiseLevel / foursquareHasWifi / foursquareTips / foursquareVerified), wired it into cafeScore as the strongest noise signal (quiet → +24, very_loud → −28), and shipped the blue-accented review-source card.
fields= parameter: attributes and tips — the exact two fields I was integrating Foursquare to get — are categorized as Premium Data and require per-call paid credits even when free-tier quota is untouched. Without those fields, the integration provided exactly one usable signal: the verified boolean. One boolean is not worth a per-pick HTTP round-trip, the 429 log noise, the extra cache-invalidation surface, the schema risk on the Restaurant model. Two options: pay for Premium credits to keep the original value proposition, or accept that the free-tier reduction made the integration a net loss. I chose the second one. Deleted enricher, the AppCoordinator hook, the four Restaurant fields, the Recommender wiring, the blue tip card. Cache version bumped v17 → v18 so the on-disk shape matches in-memory. Left a stub file with this rationale so a future me doesn't try the same experiment twice.
libswiftCore on a background queue inside FoundationModels.
FoundationModels turned off until iOS 26.6. Hard-killed both narrate() (friend-line + reasoning rewrite) and the AI fallback inside parseDishIntent. Every escalation of input sanitization across the last weeks — asciiOnly, markdownBalanced, escape-all-markdown, AttributedString parser short-circuit — bought roughly a week of stability before a new edge case landed in the tokenizer. The trap is a Swift precondition inside Apple's framework, on a background queue, untouchable by try? and unreachable from our code. Friendlier and more honest to route around: ReasoningEngine's deterministic friend-line templates and the 80+ entry keyword-dictionary dish parser cover the same UX surface (warmer copy lost, never crashes). Re-enable is two return nil deletions when iOS 26.6+ ships a tokenizer fix. The honest story is choosing reliability over a marginally-warmer sentence — the same kind of trade-off as build 25's delete.
Enter password
Picture left: Eat mode, $$$$ budget, recommender picks Starbucks Coffee Company. Even with the "below your $" honesty fact rendered, this is a category miss — the user asked for dinner. The cafe pool and the dining pool used to share a Google search and differ only in scoring weights. Build 19 split them at the API layer: cafe primaries are excluded from Eat fetches, restaurant primaries are excluded from Cafe fetches, and the two contexts become truly disjoint pools.
"Closest match in your range" pill on the card was correct — Starbucks really was the closest 4-min-walk venue. The bug was the pool, not the scorer.
Enter password
Reading this top-to-bottom is the design discipline: every fix paid down a specific human moment. Not a sprint plan, not a roadmap — feedback in, fix out, ship.
Enter password
Day-zero hypothesis: pick a mood, get a place. Day-ten reality: testers wanted to say what they wanted. Removing "I'm hungry" and "Comfort food" cut the mood taxonomy in half; adding the free-text input gave the LLM its first job (PARSE) and let users phrase real cravings — "ramyon", "fish that doesn't smell", "curry-something hearty".
The "Try 'curry' or 'something spicy'" placeholder is a hint and a contract: speak normally, the app understands. Apple Foundation Models parses, the keyword dictionary is the safety net.
Enter password
Most "smart" food apps make the user adapt to the product: pick a filter, scroll a list, calibrate to what the UI offers. Bitez does the opposite — the app watches what's around the user (CarPlay connected, Bluetooth audio paired, motion classifier says automotive) and reshapes the entire surface in response. The same discipline extends to travel mode and environment preferences. Three examples of one belief: the product owes the user flexibility, not the other way around.
Enter password
Detection aggregates three independent signals — CarPlay scene role (raw-string match, no entitlement), AVAudioSession route containing bluetoothA2DP / carAudio, and CMMotionActivityManager reporting automotive at non-low confidence. Any one fires, PickView swaps for DrivingPickView: pure-black, 54pt restaurant name, 140pt buttons, voice-only input (SFSpeechRecognizer), auto-narrated friend line over CarPlay audio (AVSpeechSynthesizer with .duckOthers). Bluetooth headphones on a walk → "Use normal view" pill in the top-right escapes back.
Enter password
Walk / Bus & Subway / Bike / Drive. The selected tab decides which transport type goes to MKDirections (.walking / .transit / .automobile) — real Apple Maps ETAs, free of API cost. Bike has no native Apple API, so falls back to a tuned speed-multiplier estimate. The same selection reshapes the candidate-pool filter: a 100-minute walk preference becomes a 100-minute drive radius when the user picks Drive, so a place 2 hours' walk away (30 min by car) enters the queue. The same Wider preference means different things to different users — that's the point.
Enter password
Environment toggles bias scoring without burning extra API calls (Google returns the same pool either way — the recommender just lifts pet-friendly and easy-parking places higher when these are on). The Apple Intelligence block lets the user kill AI summaries with one tap if iOS is acting up, AND retry mid-session — auto-disable was removed because a single transient timeout used to lose AI for hours. Bottom: Force driving view in Diagnostics, persisting across launches so the in-car layout can be demoed without an actual car. Every control the user might need is a single toggle deep.
Three context layers, one principle: read the user's situation, reshape to match it, never the reverse. The in-car shell reads the car. The travel mode picker reads the user's intent. The environment toggles read declared preference. Each one removes an instance of "you'd think this app cared, but it doesn't" — the kind of small misalignment that adds up to the user closing the tab.
Everything above explains why Bitez is built the way it is. What follows is what it actually looks like — the real screens, the interaction patterns, and how to try it yourself. The app is live on the App Store — download it and try the real thing.
Detailed walkthrough notes: the mood gate logic, the calibration animation with live restaurant count, the reasoning chip behavior on a budget mismatch, the recently-presented penalty for variety, the offline / mock-data banner system, the Apple Maps fallback for food deserts, and the in-app implicit history learning loop.
Product AI isn't about adding more model. It's about deciding precisely where the model adds value the rest of the system can't. In Bitez the model parses messy human input into typed data and rewrites deterministic facts in human voice — both irreducibly language tasks. Everything else (which restaurant, which budget, which hours, which signals matter most tonight) is plain Swift the user can audit. That separation is what made the AI feel present without ever being in charge.
A single confident pick. Two AI moments — read intent, write warmth. Reasoning the user can audit. Infrastructure that doesn't cost more than it earns. That's the product. The case study is a tour of the design decisions holding those four things together.