Nicholai a7494397f2
docs(all): comprehensive documentation overhaul (#57)
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>
2026-02-07 19:17:37 -07:00

258 lines
11 KiB
Markdown
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:
```typescript
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:
```typescript
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.
```typescript
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.
```typescript
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`.
```typescript
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:
1. **Capture**: Photo is taken, copied to `compass-photos/{id}.{format}` in the app's data directory
2. **Queue**: Metadata (project ID, GPS coords, timestamp, file path) added to the queue with `pending` status
3. **Upload**: When online, `processQueue()` iterates pending items, uses `@capgo/capacitor-uploader` to POST each file with metadata headers
4. **Cleanup**: Successfully uploaded photos are deleted from the filesystem and removed from the queue
5. **Retry**: Failed uploads get retried up to 3 times. After that, they stay in `failed` status until manually retried
```typescript
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.
```typescript
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:
1. Looks up all push tokens for the target user from the `push_tokens` table
2. Sends each token a notification with platform-specific config (high priority for Android, sound + badge for iOS)
3. Auto-cleans invalid tokens: 404 responses (unregistered device) trigger token deletion
```typescript
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:
```typescript
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-hide
- `Keyboard` -- resize body on keyboard show (not viewport), dark style
- `PushNotifications` -- 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.
```typescript
// 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.