Files
arah2/.planning/research/PITFALLS.md
T
2026-10-23 22:37:55 +08:00

350 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Pitfalls Research
**Domain:** Telegram-first GPS navigation (bot + Mini App + OSM routing), KL metro v1
**Researched:** 2026-10-22
**Confidence:** HIGH for security/platform pitfalls (official Telegram/Cloudflare/OSMF sources), MEDIUM for navigation-engine UX behavior (community + known engineering practice), MEDIUM for Malaysia-specific OSM data quality.
> Phase names used below assume a v1 roadmap roughly shaped like:
> P1 Design-system foundation → P2 Bot + destination search → P3 Mini App shell + location → P4 Route display → P5 Live turn-by-turn guidance → P6 Hybrid chat guidance → P7 Hardening/private launch. If the actual phase list differs, the "phase to address" still reads naturally by role.
---
## Critical Pitfalls
### Pitfall 1: Trusting initDataUnsafe or validating initData in the wrong place
**What goes wrong:**
The Mini App receives `initData` (a Telegram-signed payload containing user id, auth_date, etc.). The most common and most fatal Mini App security error is treating anything arriving from the client as proof of identity: using `initDataUnsafe`, accepting a plain `user_id` request parameter, or "validating" the hash client-side. Result: anyone can POST `{"user_id": <anyone>}` and navigate-as-another-user, and a captured `initData` string works forever because `auth_date` freshness was never checked.
**Why it happens:**
`initDataUnsafe` is named for convenience, not as a warning label. Telegram's docs state plainly that validation must happen on the bot's server, but client-side dev flows make the unsafe path feel like it works.
**How to avoid:**
- Send the **raw** `initData` string from the Mini App to the backend (a Cloudflare Worker/Pages Function); never parse it client-side for auth.
- On the server: build the data-check string from all key–value pairs except `hash`, sorted alphabetically, joined with `\n` (URL-decode values; watch for escape characters inside values like `photo_url` — a classic implementation bug). Derive the secret key as `HMAC-SHA256(bot_token, "WebAppData")` — the key is **not** the raw bot token (a recurring mistake). Compute `HMAC-SHA256(data_check_string, secret_key)` and compare to `hash` in constant time.
- Reject payloads older than a short window (e.g. 5–10 min) by `auth_date`; exchange the validated payload **once** for your own session token and authenticate all later Mini App↔backend calls with that token.
- Never send the bot token to the client. If third-party verification is ever needed, the newer Ed25519 signature path exists (signatures are base64url **without padding** — many languages need `=` padding restored before decoding).
- Unit-test the validator with vectors from Telegram's docs plus tampered/stale payloads before anything else ships.
**Warning signs:**
Any auth code path reachable via curl with a hand-rolled JSON body; a "TODO validate" comment; an endpoint that accepts user_id as a plain parameter.
**Phase to address:** P1–P2 (build the validation utility as the first backend artifact, before any Mini App page consumes identity).
---
### Pitfall 2: Geolocation that dies silently inside the Telegram WebView
**What goes wrong:**
`navigator.geolocation` inside a third-party WebView can fail **without ever showing a permission prompt** — the error callback fires with code 1 ("User denied Geolocation") even though the user denied nothing. This is exactly what happens when the host app's WebView does not implement the geolocation permission bridge (`onGeolocationPermissionsShowPrompt` on Android). Telegram ships several clients (iOS, Android, Telegram Desktop) that differ in how — or whether — they pass the prompt and position through. The worst outcome is silent: no prompt, no position, no useful message. A navigation app built against one client's behavior can be non-functional on another.
**Why it happens:**
Mini Apps run inside Telegram's own WebView, not Chrome; the developer controls nothing about the permission bridge. Developers test in Chrome/Safari first — where the prompt works — and build the entire guidance loop on a primitive that may be dead in-device.
**How to avoid:**
- Prefer the **WebApp LocationManager** API (Bot API 8.0+: `LocationManager.init()`, access-state check, `requestLocation`) as the primary path — it exposes a location-access state and uses Telegram's own permission plumbing. Keep `navigator.geolocation` as per-client fallback.
- Build a **client test matrix from day one**: Android Telegram, iOS Telegram, Desktop, plain browser. Test permission *grant*, *deny*, *re-ask after deny* (denials may persist with no retry path), and *no-fix scenarios* (no signal, stale fix, accuracy 100m+ — normal in Malaysian high-rise canyons).
- The very first P3 vertical slice is "read position inside the Mini App on a real phone in Telegram" — nothing else is built until that works across the matrix, with a graceful denied-state screen (re-enable instructions + manual "set origin on map" fallback).
**Warning signs:**
"Works in browser dev; fires PERMISSION_DENIED on device with no prompt ever shown." Devs testing only in Chrome. No denied-permission screen in the design.
**Phase to address:** P3 — multi-client location permission is the exit criterion of the location slice.
---
### Pitfall 3: Turn-by-turn complexity underestimated: snapping jitter, recalculation storms, ETA whiplash, battery drain
**What goes wrong (four linked failure modes naive implementations all hit):**
1. **Snapping/jitter:** raw GPS fixes jitter ±5–30m (worse in urban canyons and multi-level roads common in KL — parallel frontage/service roads, flyovers). The puck teleports between the real road and adjacent/parallel/underneath roads; happens *because position is displayed raw*.
2. **Recalculation storms:** recalculating the full OSRM route on every off-route ping (a jitter burst easily reads as off-route) creates request storms, flickering routes — and route *oscillation*: the recalculated route confuses position matching, which recalculates again.
3. **ETA whiplash:** every recalc rewrites remaining time, so the ETA visibly jumps around — the canonical "half-built navigator" smell.
4. **Battery drain:** a continuous high-accuracy `watchPosition` at tight intervals keeps the radio awake and burns battery within minutes; combined with no screen-wake control in a webview, users get "drains my phone" reviews.
**Why it happens:**
"Position → route progress → next turn" looks trivial in demos; real navigators have a dedicated location/snapping pipeline and an explicit navigation state machine that tutorial projects never show. Web examples (Leaflet + routing plugin) draw routes, not vehicle guidance.
**How to avoid:**
- Treat guidance as an explicit **state machine**: `en-route → approaching-maneuver → maneuvering → off-route → rerouting → arrived`, with hysteresis: minimum move distance (or ≥20–30m) and heading correlation with the route before "off-route" fires, plus N consecutive bad fixes.
- **Snap the position to the route polyline** (project onto active route geometry), not to the nearest road-graph edge — enough for v1, no map-matching infra needed. Maneuver distance/time always computed from snapped progress, not raw fix distance.
- **Debounce rerouting:** max one OSRM call per off-route event, cool-down (e.g. ≥5–8s), collapsing pending re-requests. Also protects the Cloudflare subrequest/CPU budgets (Pitfall 8).
- **Smooth user-visible ETA:** only surface a recalc if the change is meaningful; average speed over a window, not instantaneous GPS speed.
- Throttle position updates adaptively (≈1s while moving, 3–5s standstill); Wake Lock only during active navigation, released otherwise.
- Test with **recorded drive replays** (JSON position logs played through the state machine in CI) + a real phone mounted in a car. Golden-route replays double as the Malaysia-data regression net (Pitfall 7).
**Warning signs:**
Route fetch inside a `watchPosition` callback with no debounce/cooldown; ETA updates every tick; distance-to-turn from raw fix distance; >10% battery/10min in internal QA.
**Phase to address:** P5 (live guidance) builds it correctly; flag in P4 so the route model already carries step geometry + snapped-progress support from day one.
---
### Pitfall 4: The Telegram platform bridges are just… different: localStorage, 100vh viewport, back button
**What goes wrong (three recurring traps):**
1. **Storage:** `localStorage` inside Telegram's WebView **may be wiped** on iOS and some desktop builds; the official Telegram client storage (`CloudStorage`) exists precisely because in-webview persistence is unreliable. Session tokens cached in `localStorage` vanish → spontaneous logouts after restart.
2. **Viewport / `100vh`:** CSS `100vh` ≠ the Telegram viewport (compact/expanded states, header overlay, keyboard), and the keyboard changes `viewportHeight` while `viewportStableHeight` exists exactly for that. Layouts sized off `window.innerHeight` or `100vh` jump when the keyboard opens and slide under the Telegram header.
3. **Back button:** a custom in-page back arrow competes with Telegram's **BackButton** (Bot API 6.1+) and Android hardware back; a BackButton click with no handler closes the whole Mini App back to the chat; iOS vertical-swipe can close the app unless `disableVerticalSwipes` is called; `enableClosingConfirmation` can prevent accidental closure during navigation (but is annoying while browsing).
**Why it happens:**
Developers build a normal web app, add the `telegram-web-app.js` script tag, and meet the platform afterward.
**How to avoid:**
- Session token → `WebApp.CloudStorage` (or an HTTP-only cookie on a same-site backend); `localStorage` only for throwaway UI state.
- Shell tracks Telegram viewport: `WebApp.ready()` + `WebApp.expand()` on mount; use the viewport/`viewportStableHeight` values (or bind the SDK's CSS variables); never `100vh` for the app shell; test with the search keyboard open.
- Bind Telegram's BackButton to the router (show/hide on route change, `onClick` → router back), keep **one** navigation stack, call `disableVerticalSwipes` (Bot API 7.7+) so map interactions don't close the app, and enable closing confirmation only during active navigation.
- `WebApp.isVersionAtLeast()` / feature detection before using any advanced API; graceful fallbacks for old clients.
- Test in-device (all four clients from Pitfall 2), not just the browser.
**Warning signs:**
Content sliding under the Telegram header; keyboard shoving the route list; BackButton click dumps users to the chat mid-navigation; sessions vanishing on iOS restarts.
**Phase to address:** P3 (Mini App shell) — the shell must be Telegram-aware from the first commit or every screen inherits the debt.
---
### Pitfall 5: Nominatim as the production geocoder — rate limits and local-landmark weakness
**What goes wrong:**
Two-part failure. (1) Nominatim's official usage policy allows **at most 1 request/second** with "no heavy use" — a per-user search into the public instance violates the policy and gets 429/blocked (and the block looks like an app bug). (2) The queries users actually type are the weakest cases: "KLCC" (abbreviation for a **complex**: Kuala Lumpur City Centre *and* Suria KLCC), mall names ("Pavilion", "Sunway Pyramid"), subdistrict/kampung names, Malay road abbreviations ("Jln", highway codes like "DUKE", "SKVE", "Sprint"). Public-Nominatim ranking for local landmark phrasing is mediocre without a deliberate alias/index layer. Result: the flagship "search a destination" requirement — a validated core promise — fails on the marquee KL queries it was chosen for.
**Why it happens:**
Nominatim is the default "free OSM geocoder" and its local-phrasing weakness isn't obvious until someone types "KLCC" and gets something wrong — no feedback loop yet.
**How to avoid:**
- Build a small **curated landmark/alias index** for KL first: top ~200 destinations (malls, landmarks, transit hubs, attractions) with OSM ids + abbreviation/alias expansion ("KLCC" → Suria KLCC / KLCC Park / KLCC LRT station — user picks). Tiny static dataset (JSON in the bundle or KV) — makes flagship queries natively excellent regardless of geocoder choice, with zero privacy cost.
- Route fallback searches to an **OSM geocoder you can scale**: Photon (komoot) is the standard Nominatim successor (better autocomplete; self-host on a Malaysia extract) or a managed OSM provider (Geoapify, MapTiler geocoding — license-compliant). Nominatim public = dev/prototype only.
- If any public geocoding is used at all: cache aggressively (results are near-immutable per query), rate-limit client-side (debounce ≥300ms, min 3 chars), honor policy identification (User-Agent/email metadata).
- Reverse geocoding (origin label) is low-frequency but cache by geohash/rounded coords.
**Warning signs:**
"KLCC"-style queries failing or passing only via hardcoded synonyms; any per-keystroke synchronous Nominatim call path; 429s in logs; search latency >1s in-device.
**Phase to address:** P2 (destination search) — the alias index and geocoder choice are that phase's substance, not polish.
---
### Pitfall 6: OSRM demo-server dependency + traffic weighting assumed free
**What goes wrong:**
Developing against `router.project-osrm.org` and never fixing the dependency: the OSRM project itself says the demo server is **NOT SUITABLE FOR PRODUCTION USE** (e.g. leaflet-routing-machine's error text relayed from the demo policy). It also runs only the car profile on a periodically-updated planet extract: no uptime/existence guarantee, arbitrary policy throttling, and **no traffic weighting** — the traffic-update feature needs nightly upstream traffic files and specific ingestion infrastructure that no public server offers. So "we'll add live-ish ETA later" silently implies standing up a data pipeline. Another classic: not switching to a private instance *before* the launch — being the app where "routing is down" on demo-day.
**Why it happens:**
The demo API is identical to a self-hosted one, so the swap feels like "just change the URL" — until the routing requirements (profile tuning, extrusion from a Malaysia extract, traffic files) reveal a real deployment/task chain.
**How to avoid:**
- Stand up **your own tiny OSRM** when route display first ships: Geofabrik Malaysia-Singapore-Brunei .pbf extract (~50–100MB) → `osrm-extract` → `osrm-contract` → `osrm-routed`, comfortably within a small VPS/Fly/GCP VM (few GB RAM). The Cloudflare Worker proxies it (respecting Pitfall 8 budgets — this is actually a clean way to stay within subrequest/CPU limits).
- Design a **route-provider interface** in code (getRoute(from,to,profile) → geometry+steps+duration) so demo-OSRM vs self-hosted is a config swap, never a rewrite; traffic weighting later slots in without touching the client.
- Don't promise traffic-aware ETA in v1: default OSM speeds via OSRM duration-prediction is what you get; ship honest "typical, indicative" ETAs and defer the traffic feature entirely (trigger condition: enough opt-in telemetry, or third-party traffic data chosen — both later milestones with their own research).
- Cache route responses keyed on (from, to, profile) briefly (best-effort freshness) both to spare the OSRM instance and to keep the Cloudflare invocation fast.
**Warning signs:**
`router.project-osrm.org` in any production configuration; no route-timeouts/error handling; no test for the case "demo server slow/absent"; no deployment plan for the OSRM instance anywhere in the roadmap.
**Phase to address:** P4 (route display) — *before* guidance makes route calls latency-sensitive and chatty (P5).
---
### Pitfall 7: Malaysia/OSM data quality gaps — the app's is only as good as its map data
**What goes wrong:**
OSM Malaysia data has known weak zones relative to commercial maps: sparse lane-level geometry (`turn:lanes` tags largely absent on Malaysian roads), inconsistent multi-level/flyover modeling (interchanges where ground and elevated links appear to intersect), inconsistent Malay road naming/aliases (Jalan vs Jln; Lebuhraya vs federal expressway codes like DUKE/SPRINT/SKVE/SILK/KESAS), sparser building/POI coverage in fast-growing outer corridors, and incomplete address/portal/POI detail outside central KL. Each ingredient failure surfaces as a navigation bug: wrong-side/off-by-exit maneuvers, puck positioned on the wrong parallel link, destination snapping to a block centroid instead of the entrance, "turn left in 20m then left in 10m" instruction fragments from data artifacts, and search results not finding POIs that a local knows exist.
**Why it happens:**
The correctness contract of a navigator ("gets me there, tells me the right turn") depends entirely on the least-good map ingredient; contributors map what they see, not necessarily what a driver needs (turn-lane modeling is obscure and tedious; OSM tooling makes it optional).
**How to avoid:**
- Set the expectation explicitly in the roadmap: v1 quality bar = **"correct on KL main corridors, known limitations in lane-level and interchange micro-detail"** — not Google-equivalent. Manage this in copy design and docs, not by lying to users.
- Define a small **golden-route test set** (10–20 real KL driving pairs: KL Sentral → Mid Valley, Duta–Sungai Buloh, LDP/Penchala interchange, one Ampang KLC-style labyrinth, one Bangsar tangle) and replay them as regression tests through the guidance engine (doubles as Pitfall 3's replay corpus).
- Heuristics to normalize Malay road naming and highway-code abbreviations in search input (part of the alias index of Pitfall 5 — make this explicit work in the search phase, not an "if time permits").
- For routing quality bugs, capture **data-vs-code bug categories** (bad one-way? missing lane info? geometry wrong? OSM data missed a flyover/underpass?) — file to OSM-community (added-value via our app; the eventual reports feature becomes this pipeline, but is out of v1 scope per PROJECT.md). Route-level UX behaviors should not depend on lane geometry in v1 (no lane-decision colors); stick to maneuver-level guidance.
- Don't promise street/lane-level precision externally in v1; in fact omit any claim of "lane guidance" from copy — managing user expectation honestly is its own v1 feature ("less, but better").
**Warning signs:**
Golden-route tests failing on route *shape* only (data, not code); maneuver list contains nonsense micro-steps; no navigation QA on real KL corridors; product copy claims precision unmet by data.
**Phase to address:** P4 (golden routes) and P5 (guidance restraint on data-poor routes); bug-category capture from first QA session; OSM report loop explicitly excluded from v1 (and planned in the reports milestone).
---
### Pitfall 8: Cloudflare Pages/Workers limits mistaken for "we don't need websockets so we're fine"
**What goes wrong:**
Pages Functions are Workers, and Workers have quotas per invocation and per plan: **free plan = 10ms CPU per invocation, 50 subrequests per invocation, 100k requests/day** (paid: 10k subrequests, same daily CPU shape; Pages static assets serve unlimited requests). Per-user backend calls are small (binary initData→session, geocode-proxy, route-proxy, chat turn-cards). But it's easy to silently blow a single invocation's 50-subrequest ceiling by a fan-out handler (e.g. one "plan navigation" endpoint doing: alias lookup → geocoder → OSRM → map static asset) or by calling routing/geocoding per keystroke/pan, or by firing subrequest-heavy jobs post-hoc (during dev only — production plan's 10ms CPU simply isn't enough for heavy parsing/matching/blocking compute, causing silent dropped requests). The common mistake is not realizing Cloudflare *does* provide almost-free hosting — the mistake is architecting phases to require patterns (per-frame subrequests, in-Worker geo computation) that Workers is not built for.
**Why it happens:**
Pages is described as "free hosting"; the per-invocation subrequest/CPU model applies to Functions and is documented separately. With no background workers ("websockets aren't needed"), the plan model is very easy to satisfy accidentally — hard limits only surface when the architecture is wrong.
**How to avoid:**
- Architect backend so **one user action = one small invocation chain**: initData authenticate (no sub fetch), geocode (cache + 1 upstream call), route (cache + 1 OSRM call), turn-cards (1 bot API call). Never fan-out into per-item loops in a single invocation.
- Use **Cloudflare KV/Cache API** to serve the KL landmark alias index and cached routes — cache-first is the most important budget-free win (a small hotspot region + modest query diversity means routes/alias hits are extremely friendly to caching).
- Client-side debounce: search debounce ≥300ms, no per-pan route re-fetch (viewport is device-local; no route call per map move).
- Instrument during dev (`wrangler tail` shows subrequest/CPU usage and duration per invocation) and set an explicit, phase-reviewed budget ("route call ≤2 subrequests; session init ≤1; geocoder ≤1").
- Plan-time acceptance: free-plan viable at launch only if **cache-hit-rate > ~85% and ≤6 subrequests worst-case per invocation**; paid plan ($5/mo) is the documented fallback, not a panic.
- CPU: keep Worker logic light (validate, proxy, cache lookup); offload any heavy geo computation to the OSRM instance itself, not the Worker.
**Warning signs:**
`wrangler tail` showing >10 subrequests per invocation on one endpoint; any OSRM-call-per-map-pan logic; no debounce before geocoder; cache hit-rate never measured; first "slow" fan-out endpoint compiles without any cache.
**Phase to address:** P4 (route provider + caching), and keep as a recurring review item in every backend-touching phase; a "budgets audit" entry in the pre-launch hardening phase (P7).
---
### Pitfall 9: Design-fidelity death by translation — Tailwind-CDN mockups rebuilt into generic component code
**What goes wrong:**
The mockups in `stitch_minimalist_route_navigator/` are single-file HTML built with **Tailwind via the Play CDN** — a JIT-in-browser dev tool that encourages arbitrary values (`bg-[#1A1A1A]`, `text-[13.5px]`, one-off letter-spacing tweaks, hand-tuned asymmetrical spacing/micro-offsets) that give the Telemetry Rams look its life. The standard failure mode: the real app is rebuilt from a component library or normalized spacing scale and every one-off arbitrary value gets "cleaned up" into the nearest 4px bucket — cumulatively flattening the hand-tuned, optical, asymmetrical charm into yet another Tailwind dashboard. This never looks like one mistake; it's dozens of small normalization decisions, each individually defensible, that quietly destroy the project's most important intangible: the "less, but better" evidence of taste. The user has flagged this as the **#1 v1 risk**, and the trap is that the *natural act of "doing it properly" (tokens, libraries, scales)* is precisely what erases the fidelity.
**Why it happens:**
Tailwind-CDN mockups carry no tokens layer; components imported from libraries come with default spacing/rounded/metaphors that fight the design; "refactor" PRs silently restructure spacing/typography; Helvetica Neue's availability differs by platform (system font on some, fetchable elsewhere) while webfonts change optical weights and rhythm.
**How to avoid:**
- **Extract design tokens first, before any app screen**: write `design-tokens.css`/`tokens.ts` literally *from the mockup files* (colors incl. exact hexes — Signal Orange #EB8008, Functional Red #ED3F1C, Olive Green #736B1E, Soft Beige #D9D2C6, Matte Black #1A1A1A; type scale/weights; 8px grid plus documented *intentional exceptions*; radius set; icon line weights). Anything not in tokens requires a sunset/review flag, not silent deletion. The mockup HTML is the source of truth, not the Tailwind docs.
- **Decide the component approach deliberately**: no default-styled component library as the visual base in v1 (not shadcn/MUI-look as base); hand-rolled Rams components for the ~15 pieces v1 actually needs, built on tokens. Map is a container whose overlays use the same tokens (route line color = Signal Orange, etc.).
- Wire **font reality** into the design-system phase: expect Helvetica-system-font fallback vs Inter; verify optical weights survive; measure line-height rhythm in real webview render, not in Figma/license.
- **Keep fidelity verification as a named acceptance artifact** per UI-bearing phase: side-by-side gallery-vs-mockup review (rotated-mock vs real screenshot at matching viewport), approved by the user *before* screens are declared done. Pass/fail judged against *the intent* — rhythm, optical relationships, asymmetry — not pixel-diff, because wrapped-in-Telegram-chrome and font substitution guarantee pixel drift.
- If any "cleanup/refactor" PR touches spacing/color/typography without a mockup diff, reject it at review.
**Warning signs:**
Component library import in package.json (radix/shadcn MUI defaults); a diff that touches spacing/color/typography without mockup reference; screens built from memory of mockups instead of side-by-side against the live HTML; any "we'll polish fidelity at the end" plan.
**Phase to address:** P1 (tokens + component gallery, gated by user approval against mockups); re-verified at the end of every UI-bearing phase (P3, P4, P5, P6).
---
### Pitfall 10: Ops-stack scope creep — RabbitMQ/PostGIS/Grafana before any user
**What goes wrong:**
IDEAS.md lists RabbitMQ, Prometheus/Grafana, Neon (Postgres), Resend etc. — aspirational services from the vision doc. Classic greenfield-scope mistake: v1 ships nothing navigable because weeks go to infrastructure for scale that doesn't exist and for features (`incident reports`, telemetry pipelines) explicitly **out of scope** this milestone (PROJECT.md). PostGIS has no obvious v1 consumer: one region, a tiny curatable POI index, no user sessions to persist (privacy posture *helps* here — almost nothing must be stored). RabbitMQ serves incident broadcasts — deferred. This pitfall never looks like a mistake in the moment because each piece is "obviously needed eventually".
**Why it happens:**
Infrastructure feels like progress and produces no design-fidelity anxiety that blocks work; primitives (queues, dashboards) absorb low-confidence energy better than shipping does.
**How to avoid:**
- Roadmap rule stated up front: **every v1 service exists to serve a shipping requirement or it doesn't ship.** The legitimate v1 backend isolates to: one Worker (initData → session, geocoder + route proxy via cache, turn-card bot replies) + KV/Cache; at most one Postgres (Neon free) when opted-in telemetry or reports ship, **not before**.
- Replace each aspirational service in the roadmap with a **trigger condition**: "PostGIS: when incident reports start producing route-impact data" / "Grafana: when load patterns deny CF built-in logs" — this keeps ambition visible without consuming phase capacity.
- Grafana/Prometheus = nothing in v1; use Cloudflare's own invocation logs + a Telegram-bot error webhook (the bot is a free ops-alerting channel).
- Treat any mid-plan replacement of UI work with infra work as a re-scoping alarm in phase-gate review.
**Warning signs:**
"Plumbing" tickets with no user-visible change; imports of queue libraries; schema design for tables whose features are out-of-scope; dashboards with no metric consumers.
**Phase to address:** Milestone-planning discipline and every phase gate, not a specific phase; IDEAS.md aspiration list should be re-read (and sorted into "now / trigger-conditions") at milestone close.
---
## Technical Debt Patterns
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Route calls direct-to-OSRM from client code (no provider interface) | Zero setup, fastest possible route on screen | Coupled rewrite when self-hosted OSRM + caching + traffic arrives; leaks infra into UI code | Spike/demo phases only; never in the phase that ships route display |
| `localStorage` for session token | One line of code | iOS/client-dependent wipe → spontaneous logouts | Never — CloudStorage/cookie is the same one line |
| `100vh` / fixed pixel layout | Looks right immediately in dev | Keyboard/header shifts break it on every client | Never — viewport bindings exist |
| Trust `initDataUnsafe` in dev, "validation later" | Auth works instantly | Security hole ships if "later" slips; auth-shape rewrite | Never — the validator is one file; build it in the first backend commit |
| Demo OSRM + public Nominatim without a provider abstraction | Day-1 route+search | Policy violation/rate walls at launch scale; forced rewrite at both call sites | Spike/prototype only |
| Full route recalc on each off-route ping | Simple code path | Request storms, flicker, ETA whiplash | Never — debounce is ~10 lines |
| Skipping golden-route test corpus | Faster start | Data/code bugs invisible until real drivers complain; no regression net for guidance changes | Only if human-corridor QA is explicitly scheduled within days of the guidance phase |
| Tailwind-Play-CDN styles carried into production app code | Fastest build path | No tokens layer → design fidelity decays in every subsequent PR | Never — extract tokens in P1 instead |
| Hardcoding "websockets not needed" as "no server push" | Simplifies P3 planning | Hybrid guidance (P6) needs message timing; re-planning when chat turn-cards arrive | OK for UI-only phases; revisit when P6 design starts |
## Integration Gotchas
| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| Telegram initData | HMAC with raw bot token as key; unsorted data-check-string; skipped URL decoding; no `auth_date` freshness check | Key = HMAC(bot_token, "WebAppData"); sorted `\n`-joined string; constant-time compare; short TTL + session-token exchange; test with official vectors + tampered payloads |
| Telegram WebApp JS API | Calling features without `WebApp.isVersionAtLeast`; skipping `ready()`; assuming BackButton exists on every client | Feature-detect with version gates; graceful fallback screens; SDK init before any API call |
| Telegram LocationManager | Assuming `navigator.geolocation` behaves identically in every client; skipping the access pre-check | Check LocationManager access state first; fall back to `navigator.geolocation`; always weapon the denied screen + manual-origin fallback |
| Nominatim | Per-keystroke calls; no cache; exceeding 1 rps by design | Debounce ≥300ms; KV/Cache; own POI alias index; Photon/managed geocoder as the primary |
| OSRM | Demo server in production; ignoring mismatch 1-overview vs steps geometry scale; polyline decoded at wrong precision (5 vs 6); forgetting `steps=true` for guidance | Own instance; unit-tested response parser; polyline6 decode; always request `steps` for guidance |
| Map tiles | Hotlinking free tiers (style and quota burn); tile style visually indifferent/blank clash shapes with Rams basics | Pay/hosr tiles; apply muted Rams-compatible basemap styling (vector styles or CSS filtered raster); client-side tile caching |
| Cloudflare Workers/Pages | Fan-out subrequests in one invocation; no caching; treating .dev vars as secrets | One function per user intent; KV/Cache-first; secrets in dashboard bindings; `wrangler tail` during dev |
## Performance Traps
| Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------|
| Per-keystroke geocoding without debounce/cache | Request count ≈ keystrokes × users; 429s; battery | Debounce ≥300ms, min 3 chars, KV cache, local alias index first | Immediately at search ship; hundreds of QPS with a handful of users |
| `watchPosition` at max frequency for whole session | Battery death; UI churn; health-app mistrust | Speed-adaptive sampling; stop when app idle/suspended; Wake Lock only during active guidance | Noticeable in QA drain test; ship-blocker above ~8–10%/hour |
| Re-fetching full route geometry on map pan/zoom | Workers subrequest storm; janky map | Geometry fetched once per navigation session; viewport decisions are device-local | With the first handful of concurrent navigators |
| Route fetch + geocode + POI enrichment in one invocation > free-plan subrequest ceiling | Invocation aborts with opaque limit errors | Cap fan-out, cache, split into separate invocations | The day "plan navigation" composes three things — usually at pre-launch testing |
| Bundling all KL POIs into the client bundle | MB-scale JS; slow first open on 4G | POI search stays server-side/aliased; client ships route + narrow nearby-detail JSON on demand | First field test in-device |
## Security Mistakes
| Mistake | Risk | Prevention |
|---------|------|------------|
| Client-side initData validation or trusting `initDataUnsafe` | Full identity spoofing — navigate as any user, spam any scoped API | Server-side HMAC validation (Pitfall 1); session-token exchange |
| Bot token shipped in client bundle or exposed in Pages build env | Bot hijack, spam, forged initData | Token only in Worker secret bindings; never client `"NEXT_PUBLIC_"`-style exposure |
| Unvalidated `start_param` deep-links | Injection into Mini App navigation logic; stats/games abuse | Treat start_param as untrusted; whitelists only |
| Turn-cards or route sharing w/o rate limits | Spam vector; report-feature abuse when it later ships | v1: private-chat flows only; per-(user, minute) rate limits; reports feature gets identity + rate-limit design before it ships |
| Server-side location/track persistence "for debugging" | Direct contradiction of the privacy promise; irrecoverable trust hit | No location persistence in v1; ops logs redact coordinates (round to ~100m if ever needed) |
## UX Pitfalls
| Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------|
| Search that doesn't know local landmark language ("KLCC", mall names, subdistrict names) | Faith in the marquee feature breaks first | Alias/POI layer (Pitfall 5); tune from real tester queries |
| Guidance UI demanding driver attention (dense lists, small taps while moving) | Unsafe + app abandonment | Rams restraint as an advantage: next-maneuver only, large type, distance-first |
| No screen-awake handling — display sleeps mid-navigation (or GPS runs forever after idle) | Anger in both directions | Wake Lock only during active-navigation state (test per-client support); degrade to Telegram chat turn-cards (P6 hybrid) |
| Permission prompts at the worst moment (launch straight into guidance with no location) | Denial reflex | Prompt only at the "Start from here?" moment with one-tap confirm; map-origin fallback always available |
| Raw-fix puck floating off-road | Distrust ("is this app even right?") | Snap to route; honest "recalculating…" state (Rams microcopy) |
| ETA flipping as the vehicle moves | "Numbers wiggling, tired of this" | Smoothing; deltas shown only when meaningful (Pitfall 3) |
| Guidance UI lingers forever past arrival (no arrival detection) | Feels unfinished, buggy | Arrival detection on snapped progress → brief completion card → done state |
## "Looks Done But Isn't" Checklist
- [ ] **Auth:** tampered hash rejected in CI; stale `auth_date` (e.g. 10-min old) rejected; raw-curl calls rejected; token rotation procedure documented
- [ ] **Location:** Android/iOS/Desktop/browser matrix tested including POST-deny retry + indoor no-fix behavior; denied screen and manual-origin fallback shipped
- [ ] **Shell:** keyboard-open doesn't shove content; Telegram header never overlaps; BackButton works and never double-pops during active navigation; vertical swipe doesn't close app mid-guidance; sessions survive restart on iOS
- [ ] **Guidance:** recorded-drive replay passes; one reroute per off-route event (not a storm); route-ETA stability follows the smoothing rules
- [ ] **Golden routes:** 10–20 real KL pairs return sane routes and readable maneuvers (no 12-turn-in-30m sequences on ramp data artifacts)
- [ ] **Battery/screen:** hourly drain < ~10% in QA; wake-lock behaves exactly across active navigation vs browsing vs backgrounded
- [ ] **Cloudflare budget:** subrequest/CPU usage measured under replay load; cache hit-rate visible in logs; worst-case invocation within free-plan ceiling (or paid-plan budget accepted)
- [ ] **Design fidelity:** tokens + component gallery approved by the user side-by-side against mockups (named artifact in the design-system phase), re-verified at every UI phase close
- [ ] **Privacy:** no coordinates in any server log; opt-in telemetry only; opt-out exists and works; storage list ("what we store and why") present in the bot or in docs
## Recovery Strategies
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| initData validation shipped broken | LOW (pre-launch) / HIGH (post-users) | Write validator + vectors; invalidate all sessions; rotate the bot token if a spoof already succeeded |
| Silent geolocation failure discovered late | MEDIUM | Swap to LocationManager primary path (abstraction makes this cheap); ship denied-help screen; manual-origin fallback unblocks users meanwhile |
| Recalc storm in production | LOW | Add debounce + cooldown (small fix); the real cost is data (possible upstream 429 bans) time to recover |
| Search fails on real KL queries post-launch | MEDIUM | Alias index is additive, not architectural; add incrementally from real query logs (privacy-respecting aggregate logging only) |
| Demo OSRM throttled/gone at launch | MEDIUM | Stand up self-hosted instance (a day, not a quarter — interface means a config swap) |
| Design fidelity lost in the app | HIGH — hardest to recover | Rebuild screen-by-screen from tokens with side-by-side mockup verification; every defer costs compounded fidelity decay; precisely why the user rated this #1 |
| Ops-stack scope creep mid-build | MEDIUM | Ruthlessly remove or defer; re-scope at true v1 verticals; sunk cost hurts once, ongoing maintenance hurts every week |
## Pitfall-to-Phase Mapping
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| initData validation done right | P1/P2 (first backend artifact) | Tampered/stale payload tests in CI; curl-only requests rejected |
| Geolocation silent WebView failure | P3 (shell + location slice) | Multi-client device matrix demo includes denied + no-fix paths before phase closes |
| Snapping, recalc storms, ETA whiplash, battery | P5 (live guidance), building on route model in P4 | Recorded-drive replay passes: one reroute per off-route event; ETA stability; drain test within budget |
| localStorage / viewport / back-button traps | P3 (Telegram-aware shell) | Session survives iOS restart; keyboard test; BackButton never closes the Mini App mid-navigation |
| Nominatim limits + landmark weakness | P2 (search) | "KLCC" / "Sunway" / "Mid Valley" / alias queries pass; 1-rps budget enforced; zero 429s in logs |
| OSRM demo dependency + missing traffic infrastructure | P4 (route provider) | No demo-OSRM reference in production config; route-provider interface with two impls testable; traffic weighting deferred with explicit trigger condition |
| Malaysia OSM data quality | P4 (golden routes) × P5 (guidance restraint) | 10–20 KL golden routes pass with concise maneuvers; known-limitations doc committed |
| Cloudflare budgets | P4 onward + budgets audit in P7 | Subrequest/CPU counts instrumented in dev replay; cache hit-rate visible in logs |
| Design fidelity (Tailwind-CDN → real code) | P1 (tokens + gallery, user-approved) | Gallery-vs-mockup approval artifact per UI phase (P3–P6) |
| Ops-stack scope creep | Milestone planning + every phase gate | Every shipped service maps to a shipped requirement; no v1 infra beyond Worker/KV (+ optional Postgres only when telemetry/reports ship) |
## Sources
- Telegram official — Mini Apps / Web App platform docs (initData validation procedure, LocationManager Bot API 8.0+, BackButton Bot API 6.1+, `viewportStableHeight`, CloudStorage, disableVerticalSwipes/closing-confirmation): https://core.telegram.org/bots/webapps — HIGH
- "Telegram Mini App (TMA) Development Mistakes and How to Avoid Them" (AdsGram blog, 2025/2026) — initData traps, localStorage wipe on iOS/desktop builds, custom-back-button vs native, viewport symptom table — MEDIUM (corroborated by official docs)
- tma.js / @telegram-apps sustain issues and docs (location request patterns, viewport/css var binding, feature detection): https://github.com/Telegram-Mini-Apps/tma.js/issues/617 — MEDIUM
- WebView geolocation silent-denial references: even-realities/EvenDemoApp issue #50 (error code 1 without prompt when WebView lacks permission bridge); Stack Overflow "android webview geolocation" thread — MEDIUM (widely-reported failure mode, matches developer reports)
- Nominatim Usage Policy (official OSMF operations page; max 1 req/s, no heavy use): https://operations.osmfoundation.org/policies/nominatim/ — HIGH
- OSM wiki "Addresses" (interpolation, tagging gaps); community threads on address/POI geocoding quality and abbreviations in OSM search — MEDIUM
- OSRM official docs + demo-server policy references (leaflet-routing-machine docs' "**NOT SUITABLE FOR PRODUCTION USE**" OSRM-demo notice; traffic-update feature/datasource notes in OSRM API docs) — HIGH for policy, MEDIUM for profile nuances
- Cloudflare Workers limits docs — subrequests (50 free / 10k+ paid), CPU limits, Pages/Workers free-plan request quotas: https://developers.cloudflare.com/workers/platform/limits/ — HIGH
- Cloudflare Pages pricing docs — "static unmetered; functions metered" — HIGH
- Project-internal: PROJECT.md (Telemetry Rams token list, Tailwind-CDN mockups, privacy constraints, ops appetite, hybrid-guidance decision) and user's stated #1-risk flag on design fidelity — HIGH
- Turn-by-turn engineering traps (snapping, off-route hysteresis, ETA smoothing, adaptive sampling, drive-replay testing): synthesized from established navigation-engineering practice (routing engine vs navigation-layer separation), no single canonical source treat as MEDIUM/known-wisdom
---
*Pitfalls research for: Telegram-first GPS navigation (araharah)*
*Researched: 2026-10-22*