04. Architecture - khalillabban/Snorting-Code GitHub Wiki
Snorting-Code is a React Native (Expo) app: campus maps, Google directions (including a custom shuttle strategy), indoor floor graphs, Google Calendar schedule, and accessibility-oriented theming. Screens stay thin; services wrap external APIs; utils hold persistence, parsing, and pathfinding; components are shared UI.
Location: khalillabban/Snorting-Code/utils/parseCourseEvents.ts
The saveSchedule, loadCachedSchedule, and getNextClass functions define the contract for schedule persistence operations. These utility functions abstract AsyncStorage operations without exposing implementation details to callers.
app/schedule.tsx calls these functions to persist and retrieve cached schedule data, plugging schedule items into AsyncStorage. The storage mechanism (AsyncStorage) can be swapped without needing to change the caller.
Location: services/Routing.ts + constants/strategies.ts (interface + concrete strategies); context: services/GoogleDirectionsService.ts
Commit: https://github.com/khalillabban/Snorting-Code/commit/fbc60fdb89598e0ba8fa6b53c9db33853e4806f7
The RouteStrategy interface defines the contract; the five concrete strategies (WALKING_STRATEGY, BIKING_STRATEGY, DRIVING_STRATEGY, TRANSIT_STRATEGY, SHUTTLE_STRATEGY) are interchangeable at runtime.
GoogleDirectionsService.ts accepts any RouteStrategy and plugs its mode into the API call (with special handling for shuttle: multiple legs composed in the app). The algorithm (route mode) is swapped without needing to change the caller.
Related UI: components/StrategyModeSelector.tsx centralizes the mode picker for NavigationBar and NextClassDirectionsPanel β https://github.com/khalillabban/Snorting-Code/commit/8a330c8359be3de6b4a03b0147d02aa891f324cc
Location: services/GoogleDirectionsService.ts
Commit: https://github.com/khalillabban/Snorting-Code/commit/cc26213fc190c5fec2cbacccc1d8f979696db7b3
The file hides complexity behind two public functions (getOutdoorRoute, getOutdoorRouteWithSteps). Consumers do not see URL construction (buildDirectionsUrl), API key validation (requireGoogleApiKey), polyline decoding (decodePolyline), or HTML stripping (stripHtml).
The rest of the app treats routing as a single, simple call.
Location: utils/IndoorMapComposite.ts
Commit: https://github.com/khalillabban/Snorting-Code/commit/f00eb99b1d369b92791e4788be6285870e226056
Indoor maps are inherently hierarchical. The Composite pattern lets you treat the whole tree uniformly:
IndoorMapNode (interface: getFloors(), getRooms(), getPOIs())
- Building β contains floors
- Floor β contains rooms + POIs + hallways
- Room β leaf (name, number, type)
- POI β leaf (e.g. washroom, elevator, fountain)
US-4.4 (find room) and US-4.5 (filter POI categories) both traverse this model in documentation and tests. US-4.3 (accessible routes) can filter nodes by an accessible flag. In the shipping app, shortest-path routing runs on bundled JSON graphs via utils/indoorPathFinding.ts; IndoorMapComposite is implemented and tested (__tests__/IndoorMapComposite.test.tsx) as the hierarchical OO view of the same domain.
Location: utils/routeTransition.ts
Commit: https://github.com/khalillabban/Snorting-Code/commit/bf7436b2f9d5e3b918a935f4ff31fb476c65dfa4
Historical PR (diff): https://github.com/khalillabban/Snorting-Code/pull/312/changes/bca7ae2a54d27c187e248b111bb3246101ea36d3#diff-360e40569b2c6433668789a45b6c704b05decf8a6c7c3d897f3e594c6241d3e8
These user stories require the app to behave differently depending on where in the navigation journey the user is. The codebase does not use separate classes such as IndoorState / OutdoorState with render(). Instead it uses a discriminated union of transition payloads (mode: "indoor_to_outdoor" | "cross_building_indoor", etc.), parseTransitionPayload / serializeTransitionPayload, and branching in CampusMapScreen / IndoorMapScreen. That is State-like (encode the phase, switch behavior) without a classic GoF State class hierarchy.
Without this structured context, US-4.7 (indoor β outdoor) and US-4.8 (cross-building) would rely on even deeper nested conditionals inside those screens.
Location: contexts/ColorAccessibilityContext.tsx (ColorAccessibilityProvider, useColorAccessibility); root wrap in app/_layout.tsx; palette types in constants/theme.ts.
Commit: https://github.com/khalillabban/Snorting-Code/commit/fadf47a10eb468e5031643a5cc6be1e8095dd202
Screens and shared components read useColorAccessibility() for palette and mode instead of threading colors through props. The provider persists the userβs mode in AsyncStorage.
The Cache-Aside pattern (also called Lazy Loading) stores data in a local cache so the app can display it immediately, even before a network call completes or if the user is offline. The cache is populated after a fresh fetch, and cleared when the user disconnects.
Relevant files:
-
utils/parseCourseEvents.tsβ persistence logic -
app/schedule.tsxβ consumer (load on mount, save after fetch, clear on disconnect) -
constants/type.tsβScheduleItemtype andSCHEDULE_ITEMSstorage key
All persistence logic lives in utils/parseCourseEvents.ts:
saveSchedule(items) β Write to cache
await AsyncStorage.setItem(SCHEDULE_ITEMS, JSON.stringify(items));Called right after parseCourseEvents() succeeds. Serializes the ScheduleItem[] array as JSON and stores it under the key "scheduleItems".
loadCachedSchedule() β Read from cache
const raw = await AsyncStorage.getItem(SCHEDULE_ITEMS);
return JSON.parse(raw).map((item) => ({
...item,
start: new Date(item.start),
end: new Date(item.end),
}));Reads and deserializes the data. The critical step is reviving Date objects; JSON does not preserve Date, so start and end come back as strings and must be converted. The real implementation wraps this in try/catch, clears the key on failure, and also revives optional fields such as kind β see parseCourseEvents.ts.
getNextClass() β Query the cache
items
.filter((item) => item.start > now)
.sort((a, b) => a.start.getTime() - b.start.getTime())[0];Loads the cache and queries it in memory like a small local database.
A key design detail is the SerializedScheduleItem type (see parseCourseEvents.ts for the full shape, including kind):
type SerializedScheduleItem = Omit<ScheduleItem, "start" | "end"> & {
start: string;
end: string;
};This models the difference between in-memory state (Date objects) and stored state (ISO strings) and keeps deserialization type-safe.
App launch
ββ> loadCachedSchedule() ββββββββββββββββββ> Show stale schedule immediately
ββ> getGoogleAccessToken()
User connects / app refreshes
ββ> fetchCalendarEventsInRange()
ββ> parseCourseEvents(filteredEvents)
ββ> saveSchedule(items) ββββββββββββββββββββ> Overwrite cache with fresh data
ββ> setUi({ status: "ready", items })
User disconnects
ββ> AsyncStorage.removeItem(SCHEDULE_ITEMS) -> Cache is cleared
| Problem | Solution |
|---|---|
| Google Calendar API is slow / requires auth | Show cached data instantly on load |
Date objects don't survive JSON serialization |
SerializedScheduleItem + manual revival |
| Stale data after logout | Explicit cache clear on disconnect |
| Corrupt or invalid cache |
loadCachedSchedule catches errors and clears storage |
The Strategy Pattern defines a family of interchangeable algorithms behind a common interface, so the client can swap them at runtime without changing surrounding logic. Here, each transport mode (walk, bike, drive, transit, shuttle) is a strategy.
Relevant files:
-
services/Routing.tsβ strategy interface -
constants/strategies.tsβ concrete strategies -
services/GoogleDirectionsService.tsβ context (executes the strategy) -
components/NavigationBar.tsx/app/CampusMapScreen.tsxβ runtime selection -
components/StrategyModeSelector.tsxβ shared mode chip row
1. The strategy interface β services/Routing.ts
export interface RouteStrategy {
mode: TransportMode; // 'walking' | 'bicycling' | 'driving' | 'transit' | 'shuttle'
label: string; // shown in UI
icon: string; // icon name
extraParams?: Record<string, string>; // optional API params (e.g. transit preferences)
}This is the contract every strategy fulfills. The rest of the codebase depends on this interface, not on a concrete strategy.
2. The concrete strategies β constants/strategies.ts
export const WALKING_STRATEGY: RouteStrategy = { mode: "walking", label: "Walk", icon: "walk" };
export const BIKING_STRATEGY: RouteStrategy = { mode: "bicycling", label: "Bike", icon: "bicycle" };
export const DRIVING_STRATEGY: RouteStrategy = { mode: "driving", label: "Car", icon: "car" };
export const TRANSIT_STRATEGY: RouteStrategy = { mode: "transit", label: "Transit", icon: "bus" };
export const SHUTTLE_STRATEGY: RouteStrategy = { mode: "shuttle", label: "Shuttle", icon: "bus-clock" };
export const ALL_STRATEGIES = [
WALKING_STRATEGY,
BIKING_STRATEGY,
DRIVING_STRATEGY,
TRANSIT_STRATEGY,
SHUTTLE_STRATEGY,
];Plain data objects; no class hierarchy. ALL_STRATEGIES lets the UI render the mode picker without hardcoding modes.
3. The context β services/GoogleDirectionsService.ts
export async function getOutdoorRouteWithSteps(
origin: LatLng,
destination: LatLng,
strategy: RouteStrategy = WALKING_STRATEGY,
): Promise<OutdoorRouteResult>;The function accepts any RouteStrategy and behaves according to strategy.mode. Shuttle composes multiple sub-legs (e.g. walk β shuttle segment β walk). The caller does not need to know those details.
User picks a mode in NavigationBar (or Next Class panel)
ββ> setSelectedStrategy(strategy) // swaps the active strategy
User confirms route
ββ> handleConfirmRoute(start, dest, strategy)
ββ> getOutdoorRouteWithSteps(origin, dest, strategy)
ββ> strategy.mode === "shuttle"? β compose walk + shuttle + walk
ββ> otherwise? β build URL with strategy.mode, call Google API
| Concept | How it appears in the code |
|---|---|
| Interface |
RouteStrategy in Routing.ts
|
| Concrete strategies | Five constant objects in strategies.ts
|
| Context |
getOutdoorRouteWithSteps() in GoogleDirectionsService.ts
|
| Runtime selection |
useState<RouteStrategy> in NavigationBar, CampusMapScreen, etc. |
| Open/Closed Principle | New mode β add one object to strategies.ts and wire labels/icons |
| Data-driven UI |
ALL_STRATEGIES.map(...) β avoid duplicating mode lists |
The shuttle case is a composite routing behavior inside the service: the caller still passes SHUTTLE_STRATEGY and receives one unified OutdoorRouteResult.
The Facade Pattern provides a single, simplified interface over a complex subsystem. Here, that subsystem is the Google Directions API: HTTP, encoded polylines, HTML in step text, URL construction, and error handling. The facade hides that behind two functions.
Relevant files:
-
services/GoogleDirectionsService.tsβ the facade -
components/NavigationBar.tsx/components/CampusMap.tsxβ callers that use only the public surface
The subsystem (hidden complexity)
The file contains private helpers that callers never touch, for example:
| Function | What it does |
|---|---|
requireGoogleApiKey() |
Validates the env var; fails fast with a clear error |
buildDirectionsUrl() |
Builds the full Directions URL with query params |
decodePolyline() |
Decodes Googleβs encoded polyline to LatLng[]
|
stripHtml() |
Removes HTML from step instructions |
parseToMinutes() / formatMinutes()
|
Normalize duration strings |
None of these are exported.
Only two functions are exported:
getOutdoorRouteWithSteps(origin, destination, strategy) β full result: coordinates, segments, steps, duration, distance. Handles shuttle multi-leg composition and missing API key gracefully.
getOutdoorRoute(origin, destination, strategy) β thin wrapper for callers that only need the polyline:
export async function getOutdoorRoute(
origin: LatLng,
destination: LatLng,
strategy: RouteStrategy = WALKING_STRATEGY,
): Promise<LatLng[]> {
const { coordinates } = await getOutdoorRouteWithSteps(origin, destination, strategy);
return coordinates;
}Callers (NavigationBar, CampusMap)
β
β getOutdoorRouteWithSteps(origin, dest, strategy)
β getOutdoorRoute(origin, dest, strategy)
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β GoogleDirectionsService.ts β β FACADE
β β
β requireGoogleApiKey() β
β buildDirectionsUrl() β β hidden subsystem
β fetch() + error handling β
β decodePolyline() β
β stripHtml() β
β parseToMinutes() + formatMinutes() β
β Shuttle multi-leg composition β
βββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
Google Directions API (HTTP)
-
Encapsulation of complexity β Shuttle may require several API calls and merging geometry; callers only pass
SHUTTLE_STRATEGY. -
Layered facade β
getOutdoorRoutesits on top ofgetOutdoorRouteWithSteps. - Graceful degradation β Missing API key yields an empty-but-valid result instead of crashing the UI.
- No leaking abstractions β Raw response shapes and helpers stay inside the module.
-
Testability β Consumers mock
getOutdoorRouteWithStepswithout stubbing HTTP directly.
The Composite Pattern represents hierarchical structures where individual objects and groups are treated uniformly. Indoor maps naturally follow this structure: buildings contain floors, floors contain rooms and points of interest.
Relevant files:
utils/IndoorMapComposite.tsutils/indoorPathFinding.ts__tests__/IndoorMapComposite.test.tsx
interface IndoorMapNode {
getFloors(): Floor[];
getRooms(): Room[];
getPOIs(): POI[];
}Concrete elements:
- Building contains floors
- Floor contains rooms, POIs, hallways
- Room is a leaf
- POI is a leaf
- Room search traverses the structure recursively
- POI filtering applies the same logic across all nodes
- Accessibility filtering can be applied at any level
The system can treat a full building the same way it treats a single floor or room when querying data.
| Problem | Solution |
|---|---|
| Indoor maps are nested and complex | Composite provides a uniform traversal model |
| Features need to work at multiple levels | Same interface works for all nodes |
| Future expansion of map data | New node types plug into the same structure |
This is a State-like pattern where navigation behavior changes depending on the current routing phase, but without full class-based State objects.
Relevant files:
utils/routeTransition.tsapp/CampusMapScreen.tsxapp/IndoorMapScreen.tsx
Instead of separate state classes, the app uses a discriminated union:
type TransitionPayload =
| { mode: "indoor_to_outdoor"; data: ... }
| { mode: "cross_building_indoor"; data: ... };-
parseTransitionPayloadinterprets the current navigation state - Screens branch behavior based on
mode - Different routing flows are triggered depending on transition type
User selects destination in another building
ββ> mode = "cross_building_indoor"
ββ> IndoorMapScreen handles exit path
ββ> CampusMapScreen handles outdoor route
ββ> IndoorMapScreen handles final entry
Behavior changes based on structured state instead of deeply nested conditionals.
| Problem | Solution |
|---|---|
| Multiple navigation flows | Encoded as explicit modes |
| Hard to manage conditional logic | Centralized transition parsing |
| Extending routing behavior | Add new mode instead of rewriting logic |
The Provider Pattern uses React Context to share global state across the app without prop drilling.
Relevant files:
contexts/ColorAccessibilityContext.tsxconstants/theme.tsapp/_layout.tsx
const ColorAccessibilityContext = createContext(...);
export function useColorAccessibility() {
return useContext(ColorAccessibilityContext);
}- App is wrapped in
ColorAccessibilityProvider - Components call
useColorAccessibility() - Theme and mode are applied dynamically
const { colors, mode } = useColorAccessibility();No need to pass theme props through multiple components.
- User preference is stored in AsyncStorage
- Mode is restored on app launch
Global UI state is centralized and accessible anywhere in the component tree.
| Problem | Solution |
|---|---|
| Prop drilling across many components | Context provides global access |
| User preferences need persistence | Stored and restored automatically |
| Consistent theming across screens | Single source of truth |
Major runtime dependencies (see package.json for exact versions):
| Area | Packages |
|---|---|
| App shell |
expo, expo-router, react, react-native
|
| Maps | react-native-maps |
| Storage / OAuth helpers |
@react-native-async-storage/async-storage, expo-secure-store, expo-auth-session, expo-web-browser
|
| Location | expo-location |
| Networking | axios |
| UI / motion |
react-native-reanimated, react-native-gesture-handler, react-native-screens, react-native-safe-area-context, @expo/vector-icons, react-native-svg, expo-image
|
| Analytics |
@react-native-firebase/app, @react-native-firebase/analytics, react-native-smartlook-analytics
|
Dev / test: jest, jest-expo, @testing-library/react-native, typescript, eslint, eslint-config-expo. E2E flows use Maestro (flows under .maestro/, not an npm dependency in this repo).
This section outlines technical choices for the project, including alternatives that were considered and the decisions that were taken.
React Native + Expo
- Pros: Single codebase for iOS and Android; fast iteration; strong ecosystem for maps, location, storage, and UI.
- Cons: Advanced native features (e.g. precise indoor positioning) may need custom native modules.
Flutter
- Pros: Good performance; consistent UI.
- Cons: Dart learning curve; less team familiarity.
Native iOS / Android (Swift / Kotlin)
- Pros: Full native API access; best raw performance.
- Cons: Two codebases; higher maintenance.
React Native + Expo was selected for development speed and maintainability within project scope and team experience.
react-native-maps (Google Maps / Apple Maps)
- Pros: Widely used; markers, polylines, overlays; good for outdoor campus maps.
- Cons: No built-in indoor navigation; limited advanced styling / offline.
Mapbox
- Pros: Styling; offline.
- Cons: Setup and licensing; heavier Expo integration.
Leaflet (via WebView)
- Pros: Flexible.
- Cons: Mobile performance and UX limits.
react-native-maps is used. Mapbox was evaluated but not required for current requirements.
expo-location
- Pros: Expo-native; simple permissions; fine for outdoor GPS.
- Cons: Limited accuracy indoors.
react-native-geolocation-service
- Pros: More control in bare React Native.
- Cons: Extra native configuration.
expo-location is used for foreground location. Indoor navigation uses graph / building context rather than indoor positioning hardware.
Google Directions API
- Pros: Reliable walking and transit routing; well documented.
- Cons: Quotas, cost, network required.
Mapbox Directions API
- Pros: Pairs with Mapbox maps.
- Cons: Ecosystem lock-in.
Open-source engines (OSRM, OpenRouteService)
- Pros: Control and openness.
- Cons: Hosting and operations.
Google Directions API backs walking, bicycling, driving, and transit. Shuttle is not only βwhatever Google transit returnsβ: the app implements a composed shuttle strategy (walk + shuttle leg + walk) using project shuttle stops and multiple Directions calls where needed, coordinated inside GoogleDirectionsService.ts.
Indoor navigation needs a map representation and a routing approach.
Custom JSON graph (nodes and edges per floor)
- Pros: Transparent; easy to annotate accessibility; fits campus scale.
- Cons: Manual authoring and updates.
GeoJSON-based indoor maps
- Pros: Good for drawing.
- Cons: Extra step to build a routable graph.
Standards (e.g. IndoorGML)
- Pros: Industry-oriented.
- Cons: Too heavy for a course-scale project.
Client-side Dijkstra / A*
- Pros: No backend; easy to explain; fine for building-sized graphs.
- Cons: Implementation care required.
JS graph libraries
- Pros: Less custom graph code.
- Cons: Accessibility rules still need custom logic.
Backend routing (e.g. Python + NetworkX)
- Pros: Clear server-side model.
- Cons: Requires hosted backend.
The app uses client-side shortest-path routing on bundled JSON graphs (utils/indoorPathFinding.ts, data under assets/maps/). IndoorMapComposite documents and tests a hierarchical Composite view of the same domain; runtime routing uses the graph representation.
Google Calendar API
- Pros: Stable; familiar to students; matches requirements.
- Cons: OAuth friction for some users.
Device-native calendar
- Pros: No Google dependency.
- Cons: Platform differences.
Google Calendar API is the calendar integration used in the app.
AsyncStorage
- Pros: Simple key-value store; good for cache and preferences.
- Cons: Not for huge datasets.
SQLite (expo-sqlite)
- Pros: Structured queries.
- Cons: Extra setup.
Realm
- Pros: Performance.
- Cons: Another stack to learn.
Bundled JSON for static campus data; AsyncStorage for schedule cache, color-accessibility mode, and other small preferences.
No backend
- Pros: Simplest deployment; fits client-only navigation.
- Cons: Data updates ship with app releases.
Django + DRF / Node (Express, Nest)
- Pros: Remote data and admin.
- Cons: Operational and course-scope cost.
No backend in the shipping architecture; optional backend remains a possible future extension.
No authentication
- Pros: Simplest.
- Cons: No cross-device account story.
Google Sign-In
- Pros: Aligns with Calendar.
- Cons: Google-only.
Firebase Auth
- Pros: Fast to add.
- Cons: Vendor coupling.
There is no separate app-wide auth product; Google OAuth is used where needed (e.g. calendar).
React Native Paper β Material Design, theming.
NativeBase β Large surface, opinionated.
Tamagui β Design-system focus, learning curve.
Custom components β Full control.
The app ships custom React Native UI with shared theme and palette (constants/theme.ts) and ColorAccessibilityContext for accessibility modes. React Native Paper is not a package.json dependency; Paper-style kits were considered but the implemented UI is in-house.
Unit and component testing
- Jest
- React Native Testing Library
End-to-end testing
- Maestro (flows under
.maestro/)
Jest and RNTL cover units and components; Maestro covers core user journeys end-to-end.
| Area | URL |
|---|---|
parseCourseEvents.ts |
https://github.com/khalillabban/Snorting-Code/commit/94c3301aa1c80e5b67fa55854e285c613c4b09d2 |
Routing.ts / strategies.ts
|
https://github.com/khalillabban/Snorting-Code/commit/cde0e9e54894daa6b16dea3505483daeff51364d |
GoogleDirectionsService.ts |
https://github.com/khalillabban/Snorting-Code/commit/cc26213fc190c5fec2cbacccc1d8f979696db7b3 |
IndoorMapComposite.ts |
https://github.com/khalillabban/Snorting-Code/commit/f00eb99b1d369b92791e4788be6285870e226056 |
routeTransition.ts |
https://github.com/khalillabban/Snorting-Code/commit/bf7436b2f9d5e3b918a935f4ff31fb476c65dfa4 |
ColorAccessibilityContext.tsx |
https://github.com/khalillabban/Snorting-Code/commit/fadf47a10eb468e5031643a5cc6be1e8095dd202 |
StrategyModeSelector.tsx |
https://github.com/khalillabban/Snorting-Code/commit/8a330c8359be3de6b4a03b0147d02aa891f324cc |