Restructure docs/ into architecture/, modules/, and development/ directories. Add thorough documentation for Compass Core platform and HPS Compass modules. Rewrite CLAUDE.md as a lean quick-reference that points to the full docs. Rename files to lowercase, consolidate old docs, add gotchas section. Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
11 KiB
Executable File
Mobile Module
The mobile module wraps Compass in a native iOS and Android app using Capacitor. It's not a separate codebase or a React Native port -- it's a WebView that loads the live Cloudflare deployment. The native layer adds device-specific capabilities: biometric authentication, push notifications, camera access with GPS tagging, offline photo queuing, and status bar theming.
The fundamental design principle: the web app must never break because of native code. Every Capacitor import is dynamic (await import()), every native feature is gated behind isNative() checks, and every native component returns null on web. If Capacitor isn't present, the app works exactly as it does in a browser.
platform detection
src/lib/native/platform.ts provides the detection layer. It checks for the Capacitor global that the native runtime injects before hydration:
function getCapacitor(): CapacitorGlobal | undefined {
if (typeof window === "undefined") return undefined
return (window as unknown as Record<string, unknown>)
.Capacitor as CapacitorGlobal | undefined
}
export function isNative(): boolean {
return getCapacitor()?.isNative ?? false
}
export function isIOS(): boolean {
return getCapacitor()?.getPlatform() === "ios"
}
export function isAndroid(): boolean {
return getCapacitor()?.getPlatform() === "android"
}
The key detail: isNative() returns false on the server (no window), false in a normal browser (no Capacitor global), and true only in the native WebView. This three-way distinction matters for SSR -- server-rendered HTML assumes web, and the native state is only known after hydration.
There's also src/lib/native/detect-server.ts for server-side detection via User-Agent:
export function isNativeApp(request: Request): boolean {
const ua = request.headers.get("user-agent") ?? ""
return ua.includes("CapacitorApp")
}
the useNative hook
src/hooks/use-native.ts wraps platform detection in a React hook using useSyncExternalStore. The snapshot never changes after initial load (Capacitor injects before hydration), so the hook is stable.
export function useNative(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
getServerSnapshot returns false (SSR assumes web). getSnapshot returns isNative(). The subscribe function is a no-op because the value never changes after mount.
Every native feature checks useNative() before attempting to load Capacitor plugins. This is the gate that prevents web breakage.
native hooks
Each native capability has its own hook:
use-native-push.ts -- push notification registration. On mount (if native), requests notification permissions, registers with APNS/FCM, listens for token registration events, and POSTs the token to /api/push/register. Also handles foreground notifications and deep-linking when a notification is tapped.
const actionListener = await PushNotifications.addListener(
"pushNotificationActionPerformed",
(action) => {
const url = action.notification.data?.url
if (typeof url === "string" && url.startsWith("/")) {
router.push(url)
}
},
)
use-native-camera.ts -- camera access with EXIF extraction. Captures photos at 85% quality, 2048px width, saves to gallery, extracts GPS coordinates and timestamp from EXIF data.
use-biometric-auth.ts -- Face ID / fingerprint authentication. Checks device capability on mount, manages enabled/prompted state in localStorage, provides authenticate() that calls NativeBiometric.verifyIdentity. The biometric lock activates after the app has been backgrounded for 30+ seconds.
use-photo-queue.ts -- the most complex hook. Combines camera capture with offline-resilient upload. Takes a photo, saves it to the device filesystem, adds metadata to the queue, and auto-uploads when connectivity returns. Listens for network state changes via @capacitor/network.
const takeAndQueuePhoto = useCallback(
async (projectId: string): Promise<CapturedPhoto | null> => {
const photo = await takePhoto()
if (!photo) return null
const id = nanoid()
const fileName = `${id}.${photo.format}`
const localPath = await savePhotoToDevice(photo.uri, fileName)
await addToQueue({
id, projectId, localPath, fileName,
lat: photo.exifData.lat, lng: photo.exifData.lng,
capturedAt: new Date().toISOString(),
})
await refresh()
return photo
},
[takePhoto, refresh],
)
offline photo queue
src/lib/native/photo-queue.ts is the persistence layer for photos captured on jobsites with spotty connectivity. It uses Capacitor's Preferences plugin (key-value storage that survives app kill) to track queue metadata, and the Filesystem plugin to store actual photo files in the app's data directory.
The queue lifecycle:
- Capture: Photo is taken, copied to
compass-photos/{id}.{format}in the app's data directory - Queue: Metadata (project ID, GPS coords, timestamp, file path) added to the queue with
pendingstatus - Upload: When online,
processQueue()iterates pending items, uses@capgo/capacitor-uploaderto POST each file with metadata headers - Cleanup: Successfully uploaded photos are deleted from the filesystem and removed from the queue
- Retry: Failed uploads get retried up to 3 times. After that, they stay in
failedstatus until manually retried
await Uploader.startUpload({
filePath: photo.localPath,
serverUrl: uploadUrl,
method: "POST",
headers: {
"X-Project-Id": photo.projectId,
"X-Photo-Id": photo.id,
"X-Captured-At": photo.capturedAt,
...(photo.lat !== undefined && { "X-GPS-Lat": String(photo.lat) }),
...(photo.lng !== undefined && { "X-GPS-Lng": String(photo.lng) }),
},
})
GPS coordinates and timestamps are passed as headers rather than multipart form fields. This keeps the upload simple (single file body) while preserving all metadata.
native components
src/components/native/ contains four components. All return null on web.
native-shell.tsx -- syncs the native status bar style with the current theme. When the app switches between light and dark mode, the status bar text color updates to match.
export function NativeShell() {
const native = useNative()
const { resolvedTheme } = useTheme()
useEffect(() => {
if (!native) return
async function syncStatusBar() {
const { StatusBar, Style } = await import("@capacitor/status-bar")
await StatusBar.setStyle({
style: resolvedTheme === "dark" ? Style.Dark : Style.Light,
})
}
syncStatusBar()
}, [native, resolvedTheme])
return null
}
biometric-guard.tsx -- wraps the app with biometric lock screen functionality. Listens for app state changes (background/foreground). If the app was backgrounded for more than 30 seconds and biometrics are enabled, it shows a full-screen lock overlay. Auto-authenticates on appear, with a fallback "Use password" button that redirects to the login page.
Also handles first-login setup: after a 2-second delay on first native launch, prompts the user to enable biometric locking. The prompt state is tracked in localStorage so it's only shown once.
offline-banner.tsx -- shows a slim amber banner when the device is offline. Uses @capacitor/network on native, falls back to navigator.onLine events on web. This component actually works on both platforms -- the web fallback is useful for PWA-like behavior.
upload-queue-indicator.tsx -- shows pending photo upload count as a pill badge. Changes appearance based on status: neutral for pending, pulsing for uploading, red for errors with "tap to retry" text.
push notifications
src/lib/push/send.ts sends push notifications via FCM HTTP v1 API. It works from Cloudflare Workers without the Firebase SDK -- just a direct HTTP POST to https://fcm.googleapis.com/v1/projects/-/messages:send.
The sender:
- Looks up all push tokens for the target user from the
push_tokenstable - Sends each token a notification with platform-specific config (high priority for Android, sound + badge for iOS)
- Auto-cleans invalid tokens: 404 responses (unregistered device) trigger token deletion
const message: FcmMessage = {
message: {
token: t.token,
notification: { title: payload.title, body: payload.body },
data: payload.data ? { ...payload.data } : undefined,
android: { priority: "high" },
apns: { payload: { aps: { sound: "default", badge: 1 } } },
},
}
Device tokens are registered via POST /api/push/register (called by use-native-push.ts on app launch) and cleaned up via DELETE /api/push/register.
Capacitor configuration
capacitor.config.ts configures the native wrapper:
const config: CapacitorConfig = {
appId: "ltd.openrangeconstruction.compass",
appName: "Compass",
webDir: "public",
server: {
url: "https://compass.openrangeconstruction.ltd",
cleartext: false,
allowNavigation: [
"compass.openrangeconstruction.ltd",
"api.workos.com",
"authkit.workos.com",
"accounts.google.com",
"login.microsoftonline.com",
],
},
}
The server.url points to the live production deployment. The app doesn't bundle a static export -- it loads the real web app in a WebView. This means native users always get the latest version without app store updates for UI changes.
allowNavigation lists domains the WebView is allowed to navigate to. This is needed for OAuth flows (WorkOS, Google, Microsoft) that redirect the user to external auth pages.
The webDir: "public" is mostly a formality for Capacitor CLI requirements. Since the app loads from a remote URL, local web assets are only used during the splash screen.
Plugins configured:
SplashScreen-- white background, 2-second display, auto-hideKeyboard-- resize body on keyboard show (not viewport), dark stylePushNotifications-- badge, sound, and alert presentation options
iOS-specific: contentInset: "automatic" for safe area handling, custom compass URL scheme.
Android-specific: mixed content disabled (HTTPS only), input capture enabled.
the dynamic import pattern
Every Capacitor plugin is loaded with dynamic await import() inside an async function, never at module scope. This is critical: Capacitor plugins only exist in the native runtime. A top-level import would crash the web app at module evaluation time.
// correct: dynamic import inside a native-gated effect
useEffect(() => {
if (!native) return
async function setup() {
const { PushNotifications } = await import("@capacitor/push-notifications")
await PushNotifications.requestPermissions()
}
setup()
}, [native])
This pattern repeats throughout the mobile module. The native check prevents the import from even being attempted on web, and the dynamic import ensures the module is only loaded when actually needed.