diff --git a/.github/AGENTS.md b/.github/AGENTS.md index d3b13191..fb8e0896 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -15,6 +15,21 @@ Prefer up-to-date sources over training data. Search for error messages, check o - **Dev server:** `npm run dev` (Vite on `:5000`, Wrangler on `:8787`) - **Stop:** `npm stop` +## iOS UI Verification + +For changes under `ios/WingDex/Views/` or shared iOS UI code, inspect the corresponding web implementation under `src/components/`, `src/styles/`, and `src/index.css` before editing. Preserve feature, state, copy, content order, and palette parity with the web source of truth while using native SwiftUI and Apple HIG conventions rather than pixel-for-pixel cloning. + +Before styling a new iOS control, find an existing control with the same function and reuse its component, modifier, symbol, label structure, font, spacing, and interaction pattern. Prefer styling in this order: + +1. An existing WingDex component or established nearby pattern. +2. Standard SwiftUI controls, styles, semantic fonts and colors, button roles, materials, and system spacing. +3. New custom styling only when the product design requires behavior or appearance the first two options cannot provide. + +Do not hand-style native controls by default. Avoid fixed font sizes, explicit foreground colors, custom padding, custom shapes, or separate icon/text styling when a standard control, `Label`, semantic role, tint, or existing modifier already provides the intended result. When custom styling is necessary, keep it minimal, Dynamic Type-safe, and consistent across every control with the same function. + +Before finishing, build the active iOS scheme with Xcode MCP, render every affected `#Preview`, and validate the installed app with iOS Simulator MCP. Exercise the changed flow with `--auto-sign-in --auto-demo-data` when authenticated data is needed, and check light and dark appearance, navigation, scrolling, sheets, Dynamic Type, and visible overlap. Report any simulator or local-server validation that could not be performed. + + ## Observability (Structured Logging) Full schema and reference in **[docs/OBSERVABILITY.md](../docs/OBSERVABILITY.md)**. diff --git a/docs/IOS_APP_PLAN.md b/docs/IOS_APP_PLAN.md index d30c8784..56a62420 100644 --- a/docs/IOS_APP_PLAN.md +++ b/docs/IOS_APP_PLAN.md @@ -666,11 +666,11 @@ The iOS `OutingDetailView` is read-only except for notes editing and outing dele iOS shows the location name as a static title. The web has a pencil icon that opens an autocomplete input. -- [ ] **Pencil icon**: SF Symbol `pencil` in the header area next to the location name -- [ ] **Edit mode**: Tap pencil -> replace the title with a `TextField` pre-filled with the current name. Show Save and Cancel buttons -- [ ] **Autocomplete**: As the user types, show suggestions filtered from all existing outing location names (client-side filter from `DataStore.outings`) -- [ ] **Save**: Call `PATCH /api/data/outings/{id}` with the new `locationName`. Update local state immediately -- [ ] **Empty name**: Resets to the default geocoded location name (or "Unknown Location") +- [x] **Pencil icon**: SF Symbol `pencil` in the header area next to the location name +- [x] **Edit mode**: Tap pencil -> replace the title with a `TextField` pre-filled with the current name. Show Save and Cancel buttons +- [x] **Autocomplete**: As the user types, show suggestions filtered from all existing outing location names (client-side filter from `DataStore.outings`) +- [x] **Save**: Call `PATCH /api/data/outings/{id}` with the new `locationName`. Update local state immediately +- [x] **Empty name**: Resets to the default geocoded location name (or "Unknown Location") **Files**: `OutingDetailView.swift` **Reference**: `OutingsPage.tsx` (edit location name section) @@ -679,10 +679,10 @@ iOS shows the location name as a static title. The web has a pencil icon that op iOS can only delete entire outings. The web has per-species delete with confirmation. -- [ ] **Delete trigger**: Trailing swipe action on each species row (trash icon, destructive style), or a trash icon button visible on each row -- [ ] **Confirmation dialog**: "Remove {species name} from outing?" with Cancel and Remove buttons -- [ ] **Execution**: Call `PATCH /api/data/observations` to mark the observation as `certainty: "rejected"` (soft delete matching web behavior) -- [ ] **UI update**: Remove the species from the visible list immediately +- [x] **Delete trigger**: Trailing swipe action on each species row with a destructive trash action; removal happens immediately and rolls back if the request fails +- [ ] **Undo (deferred)**: Evaluate native Shake to Undo as a separate change with serialized server mutations and account-safe retry handling +- [x] **Execution**: Call `PATCH /api/data/observations` to mark the observation as `certainty: "rejected"` (soft delete matching web behavior) +- [x] **UI update**: Remove the species from the visible list immediately **Files**: `OutingDetailView.swift` **Reference**: `OutingsPage.tsx` (delete observation section) @@ -691,11 +691,11 @@ iOS can only delete entire outings. The web has per-species delete with confirma Does not exist on iOS. The web has an "+ Add Species" button with taxonomy autocomplete. -- [ ] **"+ Add Species" button**: Toggle button in the species list section header. When active, shows an inline form; when inactive, shows the button label -- [ ] **Species name input**: Text field with autocomplete dropdown powered by server-side taxonomy search (`GET /api/species/search?q=...&limit=8`). Debounce input by 150ms before sending search request -- [ ] **Add action**: Tap "Add" creates a new observation with `count: 1`, `certainty: "confirmed"`, linked to this outing -- [ ] **Feedback**: Toast "{species name} added". Clear input, close form -- [ ] **Keyboard navigation**: Autocomplete results navigable with tap (no hardware keyboard expected on iOS, but support standard list selection) +- [x] **"+ Add Species" button**: Toggle button in the species list section header. When active, shows an inline form; when inactive, shows the button label +- [x] **Species name input**: Text field with autocomplete dropdown powered by server-side taxonomy search (`GET /api/species/search?q=...&limit=8`). Debounce input by 150ms before sending search request +- [x] **Add action**: Tap "Add" creates a new observation with `count: 1`, `certainty: "confirmed"`, linked to this outing +- [x] **Feedback**: Clear input and close the form after the species appears in the list; failures use a native alert +- [x] **Keyboard navigation**: Autocomplete results navigable with tap (no hardware keyboard expected on iOS, but support standard list selection) **Files**: `OutingDetailView.swift`, `DataService.swift` (species search API call) **Reference**: `OutingsPage.tsx` (add species section) @@ -704,21 +704,20 @@ Does not exist on iOS. The web has an "+ Add Species" button with taxonomy autoc Does not exist on iOS. The web has an "Export eBird CSV" button per outing. -- [ ] **Button**: "Export eBird CSV" with download icon, placed in the action buttons area at the bottom of the detail view -- [ ] **Disabled state**: Greyed out if the outing has no confirmed observations -- [ ] **Execution**: Fetch `GET /api/export/outing/{id}`, receive CSV data -- [ ] **Save**: Present `UIActivityViewController` (share sheet) with the CSV file -- [ ] **Feedback**: Toast "Outing exported in eBird Record CSV format" +- [x] **Button**: "Export eBird CSV" with download icon, placed in the action buttons area at the bottom of the detail view +- [x] **Disabled state**: Greyed out if the outing has no confirmed observations +- [x] **Execution**: Fetch `GET /api/export/outing/{id}`, receive CSV data +- [x] **Save**: Present `UIActivityViewController` (share sheet) with the CSV file +- [x] **Feedback**: Present the native share sheet after export; failures use a native alert **Files**: `OutingDetailView.swift`, `DataService.swift` -### 5.5: Tappable Coordinates / Map Link +### 5.5: Tappable Map Link -iOS shows an embedded MapKit view but has no external link. The web shows clickable coordinates that open Google Maps. +iOS shows an embedded MapKit view that opens the outing location in Apple Maps. -- [x] **Tappable coordinates**: Below or alongside the embedded map, show coordinates as tappable text: "(lat, lon)" formatted to 4 decimal places -- [x] **Tap action**: Open in Apple Maps via `MKMapItem.openMaps()` with the coordinates, or offer a choice between Apple Maps and Google Maps -- [x] **Apple Maps URL**: `maps://?q={lat},{lon}` +- [x] **Tappable map**: Tap the embedded map to open the outing location externally +- [x] **Tap action**: Open in Apple Maps via `MKMapItem.openMaps()` with the outing coordinates **Files**: `OutingDetailView.swift` @@ -736,8 +735,8 @@ Five gaps between the iOS and web species/WingDex views. iOS shows sightings without any certainty indicator. The web shows "confirmed" or "possible" badges. -- [ ] **Badge**: Show a subtle text badge ("confirmed" or "possible") next to each sighting in the sightings list -- [ ] **Styling**: "Possible" badge uses a muted/warning color to distinguish from confirmed sightings +- [x] **Certainty metadata**: Show "Confirmed" or "Possible" in each sighting row +- [x] **Styling**: "Possible" uses the system warning color; confirmed uses existing muted metadata styling **Files**: `SpeciesDetailView.swift` @@ -745,7 +744,7 @@ iOS shows sightings without any certainty indicator. The web shows "confirmed" o iOS doesn't show the count per sighting. The web shows `xN` when count > 1. -- [x] **Count display**: Show `x{count}` next to the species name in each sighting row when the observation count is greater than 1 +- [x] **Count display**: Show `x{count}` in each sighting row when the observation count is greater than 1 **Files**: `SpeciesDetailView.swift` @@ -753,8 +752,8 @@ iOS doesn't show the count per sighting. The web shows `xN` when count > 1. iOS doesn't display notes for species. The web shows a "Notes" section at the bottom of the species detail view. -- [ ] **Notes section**: If `dexEntry.notes` is non-empty, show a "Notes" section heading with the notes text below in italic -- [ ] **Read-only**: Notes are displayed but not editable from the species detail view (they can be edited on the web) +- [x] **Notes section**: If `dexEntry.notes` is non-empty, show a "Notes" section heading with the notes text below in italic +- [x] **Read-only**: Notes are displayed but not editable from the species detail view (they can be edited on the web) **Files**: `SpeciesDetailView.swift` @@ -762,9 +761,9 @@ iOS doesn't display notes for species. The web shows a "Notes" section at the bo iOS has 3 sort options (date, count, name). The web has a 4th: Family (taxonomic). -- [ ] **New sort option**: Add "Family" sort (leaf icon, SF Symbol `leaf`) to the sort menu in `WingDexView` -- [ ] **Taxonomy order data**: Lazy-load taxonomy order data (taxonomic sort order from the bundled `taxonomy.json` or a derived lookup table). Port logic from `src/lib/taxonomy-order.ts` -- [ ] **Sort behavior**: Group and sort species by their taxonomic family. Default direction: ascending (A-Z by family then by taxonomic order within family) +- [x] **New sort option**: Add "Family" sort (leaf icon, SF Symbol `leaf`) to the sort menu in `WingDexView` +- [x] **Taxonomy order data**: Reuse the bundled `taxonomy.json` array index as taxonomic sequence, matching `src/lib/taxonomy-order.ts` +- [x] **Sort behavior**: Sort species by taxonomic sequence. Default direction: ascending; unknown species sort by name at the end **Files**: `WingDexView.swift` **Reference**: `WingDexPage.tsx` (family sort), `src/lib/taxonomy-order.ts` @@ -781,27 +780,27 @@ The web shows confetti + lifer toasts when new species are added. iOS has none o ### 7.1: Confetti Animation -- [ ] **Trigger**: Fire confetti animation when `newSpeciesCount > 0` after AddPhotos save, or after eBird import adds new species -- [ ] **Implementation**: Native SwiftUI particle effect or lightweight confetti modifier. Duration ~1.4s matching web -- [ ] **Reduce Motion**: If `UIAccessibility.isReduceMotionEnabled`, skip confetti and show a subtle fade-in banner instead +- [x] **Trigger**: Fire confetti animation when `newSpeciesCount > 0` after AddPhotos save, or after eBird import adds new species +- [x] **Implementation**: Native SwiftUI Canvas + TimelineView confetti burst, ~1.4s, closed-form projectile motion +- [x] **Reduce Motion**: When `accessibilityReduceMotion` is on, skip confetti and fade the banner in (opacity-only transition) -**Files**: New `ConfettiModifier.swift` or similar, applied in `AddPhotosFlow` (done screen) and `SettingsView` (after eBird import) +**Files**: New `CelebrationOverlay.swift` (`LiferCelebration` + `.celebration()` modifier), applied in `AddPhotosFlow` (done screen) and `SettingsView` (after eBird import) ### 7.2: Lifer Toast -- [ ] **Message**: "Species added to your WingDex" banner when a species is first recorded (new to the user's life list) -- [ ] **Implementation**: SwiftUI toast/banner overlay (environment-based) that auto-dismisses after ~3 seconds -- [ ] **Haptic**: Pair with `.sensoryFeedback(.success)` haptic +- [x] **Message**: Banner lists the new species names ("+N more" past three), falling back to a count ("N new species added to your WingDex") +- [x] **Implementation**: SwiftUI banner overlay via the `.celebration()` modifier, auto-dismisses after ~3 seconds +- [x] **Haptic**: Paired with `.sensoryFeedback(.success)` on trigger +- [x] **Accessibility**: Posts a VoiceOver announcement with the banner message -**Files**: Toast/notification overlay system (environment-based, reusable across the app) +**Files**: `CelebrationOverlay.swift` (reusable `.celebration()` view modifier) ### 7.3: Haptic Feedback -- **Success haptic** (`.sensoryFeedback(.success)`): On save confirmations (AddPhotos, outing edit, species add, import) -- **Impact haptic** (`.sensoryFeedback(.impact)`): On milestones (new species, first outing) -- [ ] **Selection haptic**: On sort/filter changes, tab switches +- [x] **Success haptic** (`.sensoryFeedback(.success)`): Fires on the new-species celebration (AddPhotos save, eBird import) +- [x] **Selection haptic**: On WingDex sort field/direction changes -**Files**: Various views where save/milestone actions occur +**Files**: `CelebrationOverlay.swift`, `WingDexView.swift` ### Phase 7 Verification @@ -809,69 +808,69 @@ Add photos with a new species -> confetti animation fires + lifer toast with hap --- -## Phase 8 - Dark Mode, Auth Fixes & Error Handling +## Phase 8 - Appearance & Session Hardening -### 8.1: Define Dark Color Palette +### 8.1: System Appearance Foundations -iOS has light mode colors only. +WingDex follows the iOS system appearance. A separate appearance toggle is intentionally out of scope. -- [ ] **Web dark colors**: Background `#262e29`, card darker variant, lighter text, adjusted borders -- [ ] **Color assets**: Add dark mode variants to all custom `Color` extensions in `Theme.swift` and color sets in `Assets.xcassets` -- [ ] **Verify semantic usage**: Ensure all views use `Color.pageBg`, `Color.cardBg`, etc. and never hardcoded color values +- [x] **Adaptive palette**: All WingDex named color assets include light and dark variants +- [x] **System mode**: No `preferredColorScheme`, `UIUserInterfaceStyle`, or window-scene override forces an appearance +- [x] **Semantic color audit**: Custom UI uses adaptive/semantic colors; intentional black/white values are limited to controlled image overlays -**Files**: `Theme.swift`, `Assets.xcassets` color sets +**Files**: `Theme.swift`, `Assets.xcassets` color sets, representative views -### 8.2: Appearance Toggle Wiring +### 8.2: Light & Dark Visual Audit -Depends on Phase 4.5 (appearance toggle UI). +- [x] **Map thumbnails**: `MiniMapSnapshot` renders with the active appearance and refreshes when the system scheme changes +- [x] **Attribution contrast**: Wikipedia attribution uses the readable adaptive muted color without compounded opacity +- [x] **Representative flows**: Existing previews and simulator flows were audited in both appearances, including Sign In, Home, WingDex, Outings, Settings, detail views, Add Photos, and celebration +- [x] **UIKit surfaces**: List cells, menus, alerts, pickers, sheets, and global tint adapt correctly +- [x] **Image and card legibility**: Image overlays, Sign In foreground content, and Outing Detail card hierarchy remain legible -- [ ] **UserDefaults**: Store preference as `"light"`, `"dark"`, or `"system"` in UserDefaults -- [ ] **Apply**: On launch and on toggle change, set `overrideUserInterfaceStyle` on all connected window scenes -- [ ] **System mode**: `overrideUserInterfaceStyle = .unspecified` follows iOS system setting +**Files**: `SharedComponents.swift`, `SpeciesDetailView.swift`, representative view previews -**Files**: `Theme.swift`, `WingDexApp.swift` +### 8.3: Bearer Authentication Baseline -### 8.3: Full Dark Mode Visual Audit +The API now uses Better Auth's bearer plugin. The older middleware dual-cookie workaround is no longer the API authentication path; signed cookies remain only for passkey endpoints. -- Every view, every shared component in both light and dark -- Check contrast ratios and text legibility (especially text overlaid on images with gradient) -- Verify `UITableViewCell.appearance().backgroundColor` and other UIAppearance overrides work correctly in dark mode -- Verify map styling (`.standard` map with dark scheme) +- [x] **Bearer API calls**: `DataService` attaches the raw session token via `Authorization: Bearer` +- [x] **Passkey isolation**: Signed cookie handling remains separate and passkey-cookie failures do not blindly invalidate bearer auth +- [x] **Mobile callback compatibility**: Callback extraction accepts secure and non-secure Better Auth cookie names -### 8.4: Fix 401 on API Calls ✅ +### 8.4: Token-Aware 401 Handling -Fixed. Root cause: middleware injected bearer token with wrong cookie name on HTTPS (`better-auth.session_token` vs `__Secure-better-auth.session_token`). Fix: inject both prefixed and non-prefixed cookie names so it works regardless of the `useSecureCookies` setting. +- [x] **Intercept**: Protected API 401 responses invalidate the rejected bearer session +- [x] **Race safety**: A delayed 401 from an old token cannot sign out a replacement session +- [x] **Account isolation**: Clear account-owned in-memory data on logout/session rejection and ignore stale in-flight bulk loads +- [x] **Stale mutation guard**: Delayed profile updates cannot mutate a replacement session +- [x] **Re-authentication UX**: Return to Sign In with a one-time session-expired message; explicit logout stays silent +- [x] **No automatic mutation replay**: Do not retry arbitrary requests after sign-in until idempotency and retry policy are designed -- [x] **Dual cookie injection**: Middleware now injects both `better-auth.session_token` and `__Secure-better-auth.session_token` -- [x] **Mobile callback cookie extraction**: Reads both prefixed and non-prefixed cookie names +**Files**: `AuthService.swift`, `DataService.swift`, `DataStore.swift`, `WingDexApp.swift`, `SignInView.swift`, `SettingsView.swift` -### 8.5: 401 Auto-Retry +### 8.5: Session Expiry Handling -- [ ] **Intercept**: When any API call returns HTTP 401, intercept the response in `DataService` -- [ ] **Re-auth prompt**: Present the sign-in sheet (via a published property or notification) -- [ ] **Retry**: After successful re-authentication, retry the original API request that triggered the 401 +- [x] **Startup validation**: Treat Better Auth `200 null`, HTTP 401, and malformed successful session payloads as rejected sessions +- [x] **Outage tolerance**: Preserve cached auth on transport failures and server 5xx responses +- [x] **Foreground validation**: Silently validate the active session when the app returns to the foreground +- [x] **Local expiry**: Expired cached tokens use the same session-expired path +- [ ] **End-to-end verification**: Exercise startup rejection, foreground rejection, explicit logout, and account-data clearing in the simulator -**Files**: `DataService.swift` or a shared API middleware layer - -### 8.6: Session Expiry Handling - -- [ ] **Foreground check**: When the app returns to foreground (`scenePhase == .active`), check the stored token's age -- [ ] **Near expiry**: If the token is close to expiring, make a silent validation request to the server (`GET /api/auth/get-session`) -- [ ] **Expired**: If validation fails, prompt re-auth - -**Files**: `AuthService.swift`, `WingDexApp.swift` +**Files**: `AuthService.swift`, `WingDexApp.swift`, session tests ### 8.7: Error Handling Overhaul (iOS) -Currently errors are either silently ignored or show raw `localizedDescription` strings which are often unhelpful (e.g., "(null)" for ASAuthorization errors). Needs a systematic pass across the entire iOS app. +Errors are mapped at presentation boundaries while transport/domain errors retain status, retry, and cancellation context. Persistent failures use native empty states, and action failures use native alerts or existing inline form feedback. -- [ ] **Typed error mapping**: Create a central `AppError` enum that maps network errors, auth errors, passkey errors, and API errors to user-friendly messages. All `catch` blocks should map through this instead of using raw `localizedDescription` -- [ ] **User-facing error alerts**: Show `.alert` or banner for errors that need user action (auth failure, network unreachable). Show inline error text for recoverable errors (form validation, import conflicts) -- [ ] **Network error handling**: Detect no-connectivity (`URLError.notConnectedToInternet`) and show a clear offline banner. Detect timeouts and offer retry. Detect server errors (500) with generic "Something went wrong" message -- [ ] **Rate limit feedback**: When `POST /api/identify-bird` returns 429, show "AI identification limit reached (150/day). Try again tomorrow." with the daily limit from `Config.aiDailyRateLimit` -- [ ] **Passkey error messages**: Map all `ASAuthorizationError` codes to clear messages - `.canceled` (silent dismiss), `.notHandled` ("Passkey not available for this domain"), `.failed` ("Authentication failed") -- [ ] **Pull-to-refresh retry**: On data load failure, show the error message and let pull-to-refresh retry the request -- [ ] **Toast/banner system**: Create a reusable toast overlay (environment-based) for success and error messages. Auto-dismiss after 3-5 seconds. Used across settings saves, imports, exports, species additions +- [x] **Typed error mapping**: `AppError` maps connectivity, timeout, auth, passkey, safe API status, decoding, and cancellation cases without flattening domain errors prematurely +- [x] **Native user feedback**: Action failures use SwiftUI alerts; forms/import/passkeys retain inline feedback; failed initial loads use `ContentUnavailableView` with Retry +- [x] **Network error handling**: Offline, timeout, server, invalid-response, and cancellation cases have safe distinct behavior; cached data remains visible on refresh failure +- [x] **Rate limit feedback**: Bird identification 429 responses retain `Retry-After` and show the configured request limit from `Config.aiDailyRateLimit`; unrelated 429 responses use generic retry copy +- [x] **Passkey error messages**: Cancellation is silent, unavailable/not-handled uses neutral production copy, and failed authorization is actionable +- [x] **Pull-to-refresh retry**: Home, WingDex, and Outings preserve refresh gestures, show failure state, and expose explicit Retry when the initial load has no data +- [x] **Workflow recovery**: Add Photos stops on metadata failure, preserves state across identify/save retries, distinguishes failures from no-candidate results, and uses stable idempotent photo/observation IDs +- [x] **Native presentation decision**: Use system alerts and `ContentUnavailableView` rather than a global or hand-rolled toast/banner framework **Files**: New `AppError.swift`, update `SignInView.swift`, `DataStore.swift`, `SettingsView.swift`, all views with error states @@ -879,18 +878,19 @@ Currently errors are either silently ignored or show raw `localizedDescription` Part of [#222](https://github.com/jlian/wingdex/issues/222). Ensure consistent, structured logging across all layers. -- [ ] **iOS Logger audit**: Verify all services use `Logger` with appropriate subsystem/category. Ensure `.debug` for routine operations (request/response), `.info` for state changes (sign-in, data load), `.error` for failures. Remove any credential values from log messages -- [ ] **iOS request/response timing**: Add elapsed time logging to `DataService` API calls (time between request start and response) for performance monitoring -- [ ] **Server DEBUG flag**: All API route handlers log request entry and error details when `env.DEBUG` is set. Already implemented in middleware; extend to all `functions/api/` route files -- [ ] **Server structured format**: Use `JSON.stringify({ method, path, status, ... })` consistently for easy filtering in Wrangler terminal and Cloudflare log tailing -- [ ] **Web client debug logger**: Add a `debugLog()` utility gated on `import.meta.env.DEV` using `console.debug()`. Cover auth state changes, API calls, data mutations, flow transitions -- [ ] **No credentials in logs**: Audit all log statements across iOS, server, and web to ensure tokens, keys, and user data are never logged (log token length and presence, not values) +- [x] **iOS Logger audit**: Services use categorized `Logger` calls; expected 4xx responses are warnings, 5xx/transport failures are errors, and routine request timing is debug-level +- [x] **iOS request/response timing**: `DataService` logs elapsed time and response size for API calls +- [x] **Server structured format**: Request-scoped structured logging and trace propagation are defined in `OBSERVABILITY.md` and implemented by the shared logger/responder +- [x] **Web client debug logger**: `debugLog()` is gated on `import.meta.env.DEV` +- [x] **No credentials or user content in logs**: OAuth callback URLs, tokens/cookies, response bodies, filenames, locations, species arrays, passkey IDs, provider/database exception text, and user-authored content are excluded; `OBSERVABILITY.md` defines the safe metadata allowlist +- [x] **End-to-end trace propagation**: iOS data/auth/passkey requests and first-party web fetches emit W3C `traceparent`; iOS logs safe duration/status/byte metadata and middleware rejection/completion logs include duration +- [x] **Route compliance**: Species lookup handlers use the shared route responder and no longer rely on observability-test exemptions **Files**: All service files (iOS), all `functions/api/*.ts` files (server), new web debug utility ### Phase 8 Verification -Toggle appearance Light/Dark/System -> every screen renders correctly in dark mode -> sign in via GitHub -> all API calls succeed (no 401) -> background the app for 24+ hours -> return -> session still valid or re-auth prompted gracefully -> errors shown as user-friendly messages, not raw strings -> no credentials in any log output -> server logs structured JSON when DEBUG=1. +Switch the iOS system between Light and Dark -> representative screens and native error UI render correctly -> valid bearer API calls succeed -> rejected startup/foreground sessions return to Sign In gracefully -> old-token 401 cannot clear a replacement session -> logout clears account data -> no prior account data appears during a later sign-in -> offline/timeout/500/429 failures show safe actionable feedback and preserve retry state -> structured logs contain trace/timing metadata without credentials or user content. --- @@ -1124,10 +1124,7 @@ Features leveraging iOS platform APIs that the web cannot access. These go beyon ### 10.1: Error Handling Audit -- Network errors: show user-friendly alerts with "Retry" button, not raw error messages -- Server errors (500): "Something went wrong. Please try again." -- Rate limit (429): Show "AI identification limit reached (150/day). Try again tomorrow." with remaining count -- Offline: Graceful degradation - show cached data (Phase 9 L1) or a clear "No connection" banner +Completed in Phase 8.7. Phase 10 should perform only the final release-build and App Store submission audit for error copy and accessibility. ### 10.2: Accessibility Final Pass @@ -1239,9 +1236,9 @@ No third-party UI libraries - pure SwiftUI + system frameworks. | 3.5 | **Navigation & SignIn Rework** (3-tab + "+" layout, avatar settings sheet, SignInView matching web modal) | ✅ Done | | 3-R | **Add Photos Flow Rework** (outing review, per-photo confirm, two-tier AI, crop retry, certainty) | ⏳ In Progress | | 4 | **Settings & Profile Parity** (name, avatar, appearance, import/export, delete account) | ✅ Done | -| 5 | **Outing Detail Editing** (edit location, add/delete species, export, map link) | Not started | -| 6 | **Species Detail & WingDex Parity** (eBird lookup, badges, count, notes, family sort) | Not started | -| 7 | **Celebrations & Feedback** (confetti, lifer toast, haptics) | Not started | -| 8 | **Dark Mode, Auth Fixes & Error Handling** (palette, toggle, visual audit, 401 fix, error/logging overhaul) | Not started | +| 5 | **Outing Detail Editing** (edit location, add/delete species, export, map link) | ✅ Done | +| 6 | **Species Detail & WingDex Parity** (eBird lookup, badges, count, notes, family sort) | ✅ Done | +| 7 | **Celebrations & Feedback** (confetti, lifer toast, haptics) | ✅ Done | +| 8 | **Appearance & Session Hardening** (system appearance audit, 401/session handling, native errors, observability) | ✅ Done | | 9 | **iOS-Native Enhancements** (sharing, context menus, shortcuts, Spotlight, widgets, camera, tips, etc.) | Not started | | 10 | **Polish & App Store** (errors, accessibility, icon, TestFlight, listing, submission) | Not started | diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index abea9ac7..299d5f43 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -33,7 +33,7 @@ Standard 6-level hierarchy, controlled by `LOG_LEVEL` env var: | Level | Purpose | When to use | |---|---|---| -| `Trace` | Ultra-verbose data dumps | Full candidate arrays, range prior maps. Deep debugging only. | +| `Trace` | Ultra-verbose operational diagnostics | Safe pipeline state and aggregate maps. Never credentials or user content. | | `Debug` | Sub-step diagnostic detail | Bird-id pipeline stages, batch counts, import parsing. Local dev. | | `Info` | Significant business events | Request completion (1 per request), audit events. **Production baseline.** | | `Warning` | Client errors, degraded paths | 4xx responses, validation failures. Emitted at `warn` level and above. | @@ -84,7 +84,24 @@ LOG_FORMAT=pretty You see sub-step detail (bird-id pipeline, import parsing, batch counts) in a compact terminal-friendly format. ### Deep debugging -Temporarily set `LOG_LEVEL=trace` to see full data dumps (candidate arrays, range prior maps). Revert when done. +Temporarily set `LOG_LEVEL=trace` to see additional safe pipeline diagnostics. Trace level does not permit credentials, response bodies, user-authored content, or provider/database exception text. Revert when done. + +## Safe metadata policy + +Logs may include stable operational metadata needed to correlate and diagnose requests: + +- top-level `userId`, internal `resourceId`, trace/span IDs +- internal outing, observation, or photo IDs when they identify the affected resource +- HTTP status, method, route, duration, response byte count +- aggregate counts, booleans, enums, configured limits, and retry durations + +Logs must never include: + +- raw or signed session tokens, cookies, authorization headers, OAuth callback URLs, passkey credential IDs, or challenge payloads +- email addresses, profile image URLs/data, filenames, notes, outing/location names, species-name arrays, CSV contents, or image data +- request/response bodies, provider payloads, database/provider exception messages, or stack traces + +Use a stable operation summary plus safe metadata in catch blocks, for example: `Outing deletion failed; inspect the trace and database operation` with `{ outingId }`. The trace ID is the correlation handle; raw exception text is not a logging shortcut. ### Tracing a specific user in production Query CF Workers Logs (or log analytics) by `userId` at Info level - it's a top-level field on every log line. If you need sub-step detail for a production issue, temporarily set `LOG_LEVEL=debug` in Cloudflare Workers env vars and redeploy. Revert after investigation. @@ -192,7 +209,7 @@ Middleware resolves the session (and therefore `userId`) for non-`/api/auth/*` r 5. **Include entity context** in error messages: outing IDs, observation IDs, counts. Use the `detail` parameter for rich descriptions and `properties` for machine-queryable data. 6. **Scope resourceId** when operating on a specific entity: `createRouteResponder(log?.withResourceId('outings/' + outingId), ...)` 7. **Propagate `traceparent`** on outbound calls (web -> API, iOS -> API). -8. **Read `response.text()`** on client-side errors to surface server error details to the user. +8. **Expose only safe expected 4xx details** to clients. Never surface or log raw 5xx bodies, HTML, exception text, or provider/database payloads. ### Route handler template @@ -212,13 +229,12 @@ export const onRequestPost: PagesFunction = async context => { // ... DB operations ... route.debug('Created outing', { outingId: body.id }) return Response.json(result) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Operation failed: ${message}`, { error: message }) + } catch { + return route.fail(500, 'Internal server error', 'Outing creation failed; inspect the trace and database operation', { outingId: body.id }) } } ``` ### Choosing info vs debug -Ask: "Would an ops engineer need to see this for every request in production?" If yes -> `route.info()`. If only useful when debugging -> `route.debug()`. If it's a giant data dump -> `route.trace()`. +Ask: "Would an ops engineer need to see this for every request in production?" If yes -> `route.info()`. If only useful when debugging -> `route.debug()`. Use `route.trace()` only for additional safe operational metadata, never for raw payloads or user content. diff --git a/docs/screenshots/ios-phases-5-8/add-photos-error.png b/docs/screenshots/ios-phases-5-8/add-photos-error.png new file mode 100644 index 00000000..1501c177 Binary files /dev/null and b/docs/screenshots/ios-phases-5-8/add-photos-error.png differ diff --git a/docs/screenshots/ios-phases-5-8/initial-load-error.png b/docs/screenshots/ios-phases-5-8/initial-load-error.png new file mode 100644 index 00000000..a378c58d Binary files /dev/null and b/docs/screenshots/ios-phases-5-8/initial-load-error.png differ diff --git a/docs/screenshots/ios-phases-5-8/lifer-celebration.png b/docs/screenshots/ios-phases-5-8/lifer-celebration.png new file mode 100644 index 00000000..5afc7dc3 Binary files /dev/null and b/docs/screenshots/ios-phases-5-8/lifer-celebration.png differ diff --git a/docs/screenshots/ios-phases-5-8/mutation-error.png b/docs/screenshots/ios-phases-5-8/mutation-error.png new file mode 100644 index 00000000..a7d031b7 Binary files /dev/null and b/docs/screenshots/ios-phases-5-8/mutation-error.png differ diff --git a/docs/screenshots/ios-phases-5-8/outing-detail-dark.png b/docs/screenshots/ios-phases-5-8/outing-detail-dark.png new file mode 100644 index 00000000..56ffc7aa Binary files /dev/null and b/docs/screenshots/ios-phases-5-8/outing-detail-dark.png differ diff --git a/docs/screenshots/ios-phases-5-8/outing-location-edit.png b/docs/screenshots/ios-phases-5-8/outing-location-edit.png new file mode 100644 index 00000000..ff10dcdf Binary files /dev/null and b/docs/screenshots/ios-phases-5-8/outing-location-edit.png differ diff --git a/docs/screenshots/ios-phases-5-8/refresh-error.png b/docs/screenshots/ios-phases-5-8/refresh-error.png new file mode 100644 index 00000000..3064d439 Binary files /dev/null and b/docs/screenshots/ios-phases-5-8/refresh-error.png differ diff --git a/docs/screenshots/ios-phases-5-8/species-detail-light.png b/docs/screenshots/ios-phases-5-8/species-detail-light.png new file mode 100644 index 00000000..dcc5703f Binary files /dev/null and b/docs/screenshots/ios-phases-5-8/species-detail-light.png differ diff --git a/e2e/api-smoke.spec.ts b/e2e/api-smoke.spec.ts index 7550f561..c1769c5e 100644 --- a/e2e/api-smoke.spec.ts +++ b/e2e/api-smoke.spec.ts @@ -81,12 +81,72 @@ test.describe('API smoke (request context)', () => { }) expect(createOuting.status()).toBe(200) + const replayOuting = await api.post('/api/data/outings', { + headers: { cookie: authCookie }, + data: { + id: outingId, + startTime: '2026-02-20T08:00:00.000Z', + endTime: '2026-02-20T09:30:00.000Z', + locationName: 'Renamed API Smoke Park', + createdAt: '2099-01-01T00:00:00.000Z', + }, + }) + expect(replayOuting.status()).toBe(200) + await expect(replayOuting.json()).resolves.toMatchObject({ + id: outingId, + locationName: 'Renamed API Smoke Park', + createdAt: '2026-02-20T09:00:00.000Z', + }) + const postCreateData = await api.get('/api/data/all', { headers: { cookie: authCookie }, }) expect(postCreateData.status()).toBe(200) const postCreateJson = await postCreateData.json() - expect(postCreateJson.outings.some((outing: { id: string }) => outing.id === outingId)).toBe(true) + const savedOuting = postCreateJson.outings.find((outing: { id: string }) => outing.id === outingId) + expect(savedOuting).toMatchObject({ + id: outingId, + locationName: 'Renamed API Smoke Park', + endTime: '2026-02-20T09:30:00.000Z', + createdAt: '2026-02-20T09:00:00.000Z', + }) + + const cardinalId = `${outingId}-cardinal` + const blueJayId = `${outingId}-blue-jay` + const createObservations = await api.post('/api/data/observations', { + headers: { cookie: authCookie }, + data: [ + { + id: cardinalId, + outingId, + speciesName: 'Northern Cardinal (Cardinalis cardinalis)', + count: 1, + certainty: 'confirmed', + notes: '', + }, + { + id: blueJayId, + outingId, + speciesName: 'Blue Jay (Cyanocitta cristata)', + count: 1, + certainty: 'confirmed', + notes: '', + }, + ], + }) + expect(createObservations.status()).toBe(200) + + const rejectObservation = await api.patch('/api/data/observations', { + headers: { cookie: authCookie }, + data: { ids: [cardinalId], patch: { certainty: 'rejected' } }, + }) + expect(rejectObservation.status()).toBe(200) + const rejectPayload = await rejectObservation.json() + const remainingBlueJay = rejectPayload.dexUpdates.find( + (entry: { speciesName: string }) => entry.speciesName.startsWith('Blue Jay'), + ) + expect(remainingBlueJay?.wikiTitle).toBeTruthy() + expect(remainingBlueJay?.thumbnailUrl).toMatch(/^https:\/\//) await api.dispose() }) diff --git a/functions/_middleware.ts b/functions/_middleware.ts index 0ff3d892..cfa823d8 100644 --- a/functions/_middleware.ts +++ b/functions/_middleware.ts @@ -126,7 +126,7 @@ export const onRequest: PagesFunction = async (context) => { // --- HTTP method validation --- if (!ALLOWED_METHODS.has(method)) { - log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 405, resultDescription: `Method ${method} is not allowed; supported methods are GET, POST, PATCH, DELETE, OPTIONS` }) + log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 405, resultDescription: `Method ${method} is not allowed; supported methods are GET, POST, PATCH, DELETE, OPTIONS`, durationMs: Date.now() - start }) const methodResponse = errorResponse('Method Not Allowed', 405, { Allow: Array.from(ALLOWED_METHODS).join(', '), }) @@ -141,7 +141,7 @@ export const onRequest: PagesFunction = async (context) => { if (hasBodyMethod && rawContentLength !== null) { const parsedLength = Number(rawContentLength) if (!Number.isFinite(parsedLength) || parsedLength < 0) { - log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 400, resultDescription: 'Content-Length header is not a valid non-negative number' }) + log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 400, resultDescription: 'Content-Length header is not a valid non-negative number', durationMs: Date.now() - start }) const clResponse = errorResponse('Invalid Content-Length', 400) addTraceHeaders(clResponse, traceCtx) return clResponse @@ -150,7 +150,7 @@ export const onRequest: PagesFunction = async (context) => { const limit = BODY_LIMITS.find((b) => pathname.startsWith(b.prefix))?.maxBytes ?? DEFAULT_BODY_LIMIT if (parsedLength > limit) { - log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 413, resultDescription: `Request body of ${parsedLength} bytes exceeds the ${limit}-byte limit`, properties: { contentLength: parsedLength, limit } }) + log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 413, resultDescription: `Request body exceeded the configured route limit`, durationMs: Date.now() - start, properties: { contentLength: parsedLength, limit } }) const sizeResponse = errorResponse('Payload Too Large', 413) addTraceHeaders(sizeResponse, traceCtx) return sizeResponse @@ -225,32 +225,8 @@ export const onRequest: PagesFunction = async (context) => { /** Emit the single request-lifecycle completion log with dynamic level. */ async function emitCompletionLog(log: ReturnType, op: string, response: Response, durationMs: number, method: string, pathname: string): Promise { const status = response.status - // For error responses, try to extract a meaningful description from the body - let description = `HTTP ${status}` - if (status >= 400) { - try { - const contentType = response.headers.get('content-type') || '' - const contentLength = parseInt(response.headers.get('content-length') || '', 10) - // Only read small text/json bodies with a known size to avoid logging large or binary payloads - if ((contentType.includes('json') || contentType.includes('text')) && !isNaN(contentLength) && contentLength <= 4096) { - const cloned = response.clone() - const body = await cloned.text() - if (body) { - try { - const json = JSON.parse(body) as Record - const msg = json.message || json.error || json.detail - if (typeof msg === 'string') description = msg.slice(0, 200) - } catch { - if (body.length <= 200) description = body - } - } - } - } catch { - // Ignore read failures; keep default description - } - } const resultType = status < 400 ? 'Succeeded' : 'Failed' - const fields = { category: 'Request' as const, resultType: resultType as 'Succeeded' | 'Failed', resultSignature: status, resultDescription: description, durationMs, properties: { 'http.method': method, 'http.route': pathname } } + const fields = { category: 'Request' as const, resultType: resultType as 'Succeeded' | 'Failed', resultSignature: status, resultDescription: `HTTP ${status}`, durationMs, properties: { 'http.method': method, 'http.route': pathname } } if (status >= 500) { log.error(op, fields) } else if (status >= 400) { @@ -274,14 +250,13 @@ function handleUnexpectedError( operationName: string, start: number, ): Response { - const message = err instanceof Error ? err.message : String(err) + void err log.error(operationName, { category: 'Request', resultType: 'Failed', resultSignature: 500, - resultDescription: `Unhandled error: ${message}`, + resultDescription: 'Unhandled route error; inspect the result signature and trace ID, then retry or investigate the route implementation', durationMs: Date.now() - start, - properties: { error: message }, }) const response = errorResponse('Internal Server Error', 500) addTraceHeaders(response, traceCtx) diff --git a/functions/api/auth/finalize-passkey.ts b/functions/api/auth/finalize-passkey.ts index 4f98b1c7..0a37e154 100644 --- a/functions/api/auth/finalize-passkey.ts +++ b/functions/api/auth/finalize-passkey.ts @@ -35,7 +35,7 @@ export const onRequestPost: PagesFunction = async context => { passkeyId || undefined, ) if (!ownsPasskey) { - return route.fail(403, 'Passkey required', `User does not own passkey ${passkeyId || '(none)'} or no passkey found for this user`, { passkeyId: passkeyId || undefined }) + return route.fail(403, 'Passkey required', 'No owned passkey matched the finalization request', { passkeyIdSupplied: passkeyId.length > 0 }) } try { @@ -49,8 +49,7 @@ export const onRequestPost: PagesFunction = async context => { route.info('User finalized passkey upgrade and is no longer anonymous') return Response.json({ success: true }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Passkey finalization failed: ${message}`, { error: message, userId: session.user.id }) + } catch { + return route.fail(500, 'Internal server error', 'Passkey finalization failed; inspect the trace and database operation', { userId: session.user.id }) } } diff --git a/functions/api/auth/linked-providers.ts b/functions/api/auth/linked-providers.ts index 501727dc..a50e8bc4 100644 --- a/functions/api/auth/linked-providers.ts +++ b/functions/api/auth/linked-providers.ts @@ -40,8 +40,7 @@ export const onRequestGet: PagesFunction = async context => { return Response.json({ providers }, { headers: { 'cache-control': 'no-store' }, }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Linked providers fetch failed: ${message}`, { error: message }) + } catch { + return route.fail(500, 'Internal server error', 'Linked provider lookup failed; inspect the trace and authentication database operation') } } diff --git a/functions/api/data/all.ts b/functions/api/data/all.ts index 13f8da50..73e8be89 100644 --- a/functions/api/data/all.ts +++ b/functions/api/data/all.ts @@ -1,6 +1,5 @@ -import { computeDex } from '../../lib/dex-query' +import { computeDex, enrichDexEntries } from '../../lib/dex-query' import { hasObservationColumn } from '../../lib/schema' -import { getWikiMetadata } from '../../lib/taxonomy' import { createRouteResponder } from '../../lib/log' type OutingRow = { @@ -109,19 +108,9 @@ export const onRequestGet: PagesFunction = async context => { outings, photos, observations, - dex: dex.map(entry => { - const { wikiTitle, thumbnailUrl } = getWikiMetadata(entry.speciesName) - return { - ...entry, - addedDate: entry.addedDate || undefined, - bestPhotoId: entry.bestPhotoId || undefined, - wikiTitle, - thumbnailUrl, - } - }), + dex: enrichDexEntries(dex), }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Data fetch failed: ${message}`, { error: message }) + } catch { + return route.fail(500, 'Internal server error', 'Bulk data fetch failed; inspect the trace and database operations') } } diff --git a/functions/api/data/clear.ts b/functions/api/data/clear.ts index 0febf3c4..5cee727e 100644 --- a/functions/api/data/clear.ts +++ b/functions/api/data/clear.ts @@ -15,8 +15,7 @@ export const onRequestDelete: PagesFunction = async context => { route.info('Deleted all outings and dex metadata for user') return Response.json({ cleared: true }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Data clear failed: ${message}`, { error: message }) + } catch { + return route.fail(500, 'Internal server error', 'Data clear failed; inspect the trace and database batch') } } diff --git a/functions/api/data/dex.ts b/functions/api/data/dex.ts index 96e7b0ca..eacddc1f 100644 --- a/functions/api/data/dex.ts +++ b/functions/api/data/dex.ts @@ -1,4 +1,4 @@ -import { computeDex } from '../../lib/dex-query' +import { computeDex, enrichDexEntries } from '../../lib/dex-query' import { createRouteResponder } from '../../lib/log' type DexMetaPatch = { @@ -53,16 +53,9 @@ export const onRequestGet: PagesFunction = async context => { try { const dex = await computeDex(context.env.DB, userId) route.debug(`Computed dex with ${dex.length} species`, { speciesCount: dex.length }) - return Response.json( - dex.map(entry => ({ - ...entry, - addedDate: entry.addedDate || undefined, - bestPhotoId: entry.bestPhotoId || undefined, - })) - ) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Dex read failed: ${message}`, { error: message }) + return Response.json(enrichDexEntries(dex)) + } catch { + return route.fail(500, 'Internal server error', 'Dex read failed; inspect the trace and database query') } } @@ -89,18 +82,13 @@ export const onRequestPatch: PagesFunction = async context => { for (const patch of patches) { await upsertDexMetaPatch(context.env.DB, userId, patch) } - route.debug(`Upserted ${patches.length} dex metadata patches`, { patchCount: patches.length, speciesNames: patches.map(p => p.speciesName) }) + route.debug(`Upserted ${patches.length} dex metadata patches`, { patchCount: patches.length }) const dexUpdates = await computeDex(context.env.DB, userId) return Response.json({ - dexUpdates: dexUpdates.map(entry => ({ - ...entry, - addedDate: entry.addedDate || undefined, - bestPhotoId: entry.bestPhotoId || undefined, - })), + dexUpdates: enrichDexEntries(dexUpdates), }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Dex patch failed: ${message}`, { error: message, patchCount: patches.length }) + } catch { + return route.fail(500, 'Internal server error', 'Dex patch failed; inspect the trace and database batch', { patchCount: patches.length }) } } diff --git a/functions/api/data/observations.ts b/functions/api/data/observations.ts index 2a11be40..091835c8 100644 --- a/functions/api/data/observations.ts +++ b/functions/api/data/observations.ts @@ -1,6 +1,7 @@ -import { computeDex } from '../../lib/dex-query' +import { computeDex, enrichDexEntries } from '../../lib/dex-query' import { hasObservationColumn } from '../../lib/schema' import { createRouteResponder } from '../../lib/log' +import { queryInChunks } from '../../lib/d1-chunk' type ObservationCertainty = 'confirmed' | 'possible' | 'pending' | 'rejected' @@ -97,27 +98,29 @@ async function listObservationsByIds(db: D1Database, userId: string, ids: string const supportsSpeciesComments = await hasObservationColumn(db, 'speciesComments') const speciesCommentsSelect = supportsSpeciesComments ? 'speciesComments' : 'NULL as speciesComments' - const placeholders = ids.map(() => '?').join(', ') - const result = await db - .prepare( - `SELECT id, outingId, speciesName, count, certainty, representativePhotoId, aiConfidence, ${speciesCommentsSelect}, notes + const rows = await queryInChunks(ids, (chunk, placeholders) => + db + .prepare( + `SELECT id, outingId, speciesName, count, certainty, representativePhotoId, aiConfidence, ${speciesCommentsSelect}, notes FROM observation WHERE userId = ? AND id IN (${placeholders})` - ) - .bind(userId, ...ids) - .all<{ - id: string - outingId: string - speciesName: string - count: number - certainty: ObservationCertainty - representativePhotoId?: string | null - aiConfidence?: number | null - speciesComments?: string | null - notes: string - }>() - - return result.results.map(observation => ({ + ) + .bind(userId, ...chunk) + .all<{ + id: string + outingId: string + speciesName: string + count: number + certainty: ObservationCertainty + representativePhotoId?: string | null + aiConfidence?: number | null + speciesComments?: string | null + notes: string + }>() + .then(result => result.results) + ) + + return rows.map(observation => ({ ...observation, representativePhotoId: observation.representativePhotoId || undefined, aiConfidence: observation.aiConfidence ?? undefined, @@ -129,13 +132,68 @@ async function hasOwnedOutings(db: D1Database, userId: string, outingIds: string const uniqueOutingIds = Array.from(new Set(outingIds)) if (uniqueOutingIds.length === 0) return true - const placeholders = uniqueOutingIds.map(() => '?').join(', ') - const result = await db - .prepare(`SELECT id FROM outing WHERE userId = ? AND id IN (${placeholders})`) - .bind(userId, ...uniqueOutingIds) - .all<{ id: string }>() + const rows = await queryInChunks(uniqueOutingIds, async (chunk, placeholders) => { + const result = await db + .prepare(`SELECT id FROM outing WHERE userId = ? AND id IN (${placeholders})`) + .bind(userId, ...chunk) + .all<{ id: string }>() + return result.results + }) - return result.results.length === uniqueOutingIds.length + return rows.length === uniqueOutingIds.length +} + +async function hasCompatibleObservationIds( + db: D1Database, + userId: string, + observations: CreateObservationInput[], + requireAll = false, +): Promise { + const ids = [...new Set(observations.map(observation => observation.id))] + if (ids.length === 0) return true + const expectedOutings = new Map(observations.map(observation => [observation.id, observation.outingId])) + const existing = await queryInChunks(ids, async (chunk, placeholders) => { + const result = await db + .prepare(`SELECT id, userId, outingId FROM observation WHERE id IN (${placeholders})`) + .bind(...chunk) + .all<{ id: string; userId: string; outingId: string }>() + return result.results + }) + return (!requireAll || existing.length === ids.length) && + existing.every(observation => observation.userId === userId && observation.outingId === expectedOutings.get(observation.id)) +} + +async function hasOwnedPhotos(db: D1Database, userId: string, photoRefs: Array<{ photoId: string; outingId?: string }>): Promise { + if (photoRefs.length === 0) return true + const ids = [...new Set(photoRefs.map(ref => ref.photoId))] + const rows = await queryInChunks(ids, async (chunk, placeholders) => { + const result = await db + .prepare(`SELECT id, outingId FROM photo WHERE userId = ? AND id IN (${placeholders})`) + .bind(userId, ...chunk) + .all<{ id: string; outingId: string }>() + return result.results + }) + if (rows.length !== ids.length) return false + const photosById = new Map(rows.map(photo => [photo.id, photo])) + return photoRefs.every(ref => { + const photo = photosById.get(ref.photoId) + return !!photo && (!ref.outingId || photo.outingId === ref.outingId) + }) +} + +function hasConflictingObservationIds(observations: CreateObservationInput[]): boolean { + return new Set(observations.map(observation => observation.id)).size !== observations.length +} + +function hasConflictingPhotoRefs(observations: CreateObservationInput[]): boolean { + const outingsByPhoto = new Map() + for (const observation of observations) { + if (!observation.representativePhotoId) continue + const existing = outingsByPhoto.get(observation.representativePhotoId) + if (existing && existing !== observation.outingId) return true + outingsByPhoto.set(observation.representativePhotoId, observation.outingId) + } + return false } export const onRequestPost: PagesFunction = async context => { @@ -158,7 +216,13 @@ export const onRequestPost: PagesFunction = async context => { if (body.length === 0) { const dexUpdates = await computeDex(context.env.DB, userId) - return Response.json({ observations: [], dexUpdates }) + return Response.json({ observations: [], dexUpdates: enrichDexEntries(dexUpdates) }) + } + if (hasConflictingObservationIds(body)) { + return route.fail(400, 'Duplicate observation IDs', 'Observation payload must contain unique IDs', { count: body.length }) + } + if (hasConflictingPhotoRefs(body)) { + return route.fail(400, 'Invalid photo references', 'A representative photo cannot reference multiple outings in one request') } try { @@ -171,6 +235,15 @@ export const onRequestPost: PagesFunction = async context => { if (!allOwned) { return route.fail(400, 'Invalid outing reference', `One or more outing IDs do not belong to the requesting user`, { outingIds }) } + if (!await hasCompatibleObservationIds(context.env.DB, userId, body)) { + return route.fail(409, 'Observation ID conflict', 'One or more observation IDs already belong to another account or outing', { count: body.length }) + } + const photoRefs = body + .filter(observation => observation.representativePhotoId) + .map(observation => ({ photoId: observation.representativePhotoId!, outingId: observation.outingId })) + if (!await hasOwnedPhotos(context.env.DB, userId, photoRefs)) { + return route.fail(400, 'Invalid photo reference', 'Representative photos must belong to the requesting account and outing', { count: photoRefs.length }) + } const supportsSpeciesComments = await hasObservationColumn(context.env.DB, 'speciesComments') @@ -178,7 +251,16 @@ export const onRequestPost: PagesFunction = async context => { if (supportsSpeciesComments) { return context.env.DB.prepare( `INSERT INTO observation (id, outingId, userId, speciesName, count, certainty, representativePhotoId, aiConfidence, speciesComments, notes) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)` + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(id) DO UPDATE SET + speciesName = excluded.speciesName, + count = excluded.count, + certainty = excluded.certainty, + representativePhotoId = excluded.representativePhotoId, + aiConfidence = excluded.aiConfidence, + speciesComments = excluded.speciesComments, + notes = excluded.notes + WHERE observation.userId = excluded.userId AND observation.outingId = excluded.outingId` ).bind( observation.id, observation.outingId, @@ -195,7 +277,15 @@ export const onRequestPost: PagesFunction = async context => { return context.env.DB.prepare( `INSERT INTO observation (id, outingId, userId, speciesName, count, certainty, representativePhotoId, aiConfidence, notes) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)` + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(id) DO UPDATE SET + speciesName = excluded.speciesName, + count = excluded.count, + certainty = excluded.certainty, + representativePhotoId = excluded.representativePhotoId, + aiConfidence = excluded.aiConfidence, + notes = excluded.notes + WHERE observation.userId = excluded.userId AND observation.outingId = excluded.outingId` ).bind( observation.id, observation.outingId, @@ -210,12 +300,15 @@ export const onRequestPost: PagesFunction = async context => { }) await context.env.DB.batch(statements) + if (!await hasCompatibleObservationIds(context.env.DB, userId, body, true)) { + return route.fail(409, 'Observation ID conflict', 'One or more observation IDs were concurrently claimed by another account or outing', { count: body.length }) + } const speciesNames = [...new Set(body.map(o => o.speciesName))] const scopedRoute = outingIds.length === 1 ? createRouteResponder(route.log?.withResourceId(`outings/${outingIds[0]}/observations`), 'data/observations/write', 'Application') : route - scopedRoute.info(`Inserted ${body.length} observations for ${speciesNames.length} species in ${outingIds.length} outings`, { observationCount: body.length, speciesCount: speciesNames.length, outingCount: outingIds.length }) - scopedRoute.debug('Observation insert details', { outingIds, speciesNames }) + scopedRoute.info(`Persisted ${body.length} observations for ${speciesNames.length} species in ${outingIds.length} outings`, { observationCount: body.length, speciesCount: speciesNames.length, outingCount: outingIds.length }) + scopedRoute.debug('Observation insert details', { outingCount: outingIds.length, speciesCount: speciesNames.length }) const observations = body.map(observation => ({ ...observation, @@ -226,10 +319,9 @@ export const onRequestPost: PagesFunction = async context => { })) const dexUpdates = await computeDex(context.env.DB, userId) - return Response.json({ observations, dexUpdates }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Observation insert failed: ${message}`, { error: message, count: body.length }) + return Response.json({ observations, dexUpdates: enrichDexEntries(dexUpdates) }) + } catch { + return route.fail(500, 'Internal server error', 'Observation persistence failed; inspect the trace and database operation', { count: body.length }) } } @@ -278,6 +370,20 @@ export const onRequestPatch: PagesFunction = async context => { return route.fail(400, 'Invalid outing reference', `Outing ${patch.outingId} is not owned by user or does not exist`, { outingId: patch.outingId }) } } + if (typeof patch.outingId === 'string' || 'representativePhotoId' in patch) { + const current = await listObservationsByIds(db, userId, [id]) + const currentObservation = current[0] + if (!currentObservation) { + return route.fail(404, 'Not found', `Observation ${id} not found or not owned by user`, { observationId: id }) + } + const photoId = 'representativePhotoId' in patch + ? patch.representativePhotoId + : currentObservation.representativePhotoId + const outingId = patch.outingId ?? currentObservation.outingId + if (typeof photoId === 'string' && !await hasOwnedPhotos(db, userId, [{ photoId, outingId }])) { + return route.fail(400, 'Invalid photo reference', 'Representative photo must belong to the requesting account and outing', { observationId: id }) + } + } const updateResult = await db .prepare(`UPDATE observation SET ${updateFields.join(', ')} WHERE id = ? AND userId = ?`) @@ -291,7 +397,7 @@ export const onRequestPatch: PagesFunction = async context => { const updated = await listObservationsByIds(db, userId, [id]) const dexUpdates = await computeDex(db, userId) - return Response.json({ observation: updated[0], dexUpdates }) + return Response.json({ observation: updated[0], dexUpdates: enrichDexEntries(dexUpdates) }) } if (Array.isArray(body.ids) && body.ids.every(id => typeof id === 'string') && isObject(body.patch)) { @@ -320,6 +426,20 @@ export const onRequestPatch: PagesFunction = async context => { return route.fail(400, 'Invalid outing reference', `Outing ${patch.outingId} is not owned by user or does not exist`, { outingId: patch.outingId }) } } + if (typeof patch.outingId === 'string' || 'representativePhotoId' in patch) { + const current = await listObservationsByIds(db, userId, ids) + const photoRefs = current.flatMap(observation => { + const photoId = 'representativePhotoId' in patch + ? patch.representativePhotoId + : observation.representativePhotoId + return typeof photoId === 'string' + ? [{ photoId, outingId: patch.outingId ?? observation.outingId }] + : [] + }) + if (current.length !== ids.length || !await hasOwnedPhotos(db, userId, photoRefs)) { + return route.fail(400, 'Invalid photo reference', 'Representative photo must belong to the requesting account and every target outing', { count: ids.length }) + } + } const statements = ids.map(id => db @@ -337,12 +457,11 @@ export const onRequestPatch: PagesFunction = async context => { const observations = await listObservationsByIds(db, userId, ids) const dexUpdates = await computeDex(db, userId) - return Response.json({ observations, dexUpdates }) + return Response.json({ observations, dexUpdates: enrichDexEntries(dexUpdates) }) } return route.fail(400, 'Invalid patch payload', 'PATCH payload does not match single-id or bulk-ids shape') - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Observation patch failed: ${message}`, { error: message }) + } catch { + return route.fail(500, 'Internal server error', 'Observation patch failed; inspect the trace and database operation') } } diff --git a/functions/api/data/outings.ts b/functions/api/data/outings.ts index 7caad5cc..190986b2 100644 --- a/functions/api/data/outings.ts +++ b/functions/api/data/outings.ts @@ -62,123 +62,150 @@ export const onRequestPost: PagesFunction = async context => { } try { + const existing = await context.env.DB + .prepare('SELECT userId, createdAt FROM outing WHERE id = ?') + .bind(body.id) + .first<{ userId: string; createdAt: string }>() + if (existing && existing.userId !== userId) { + return route.fail(409, 'Outing ID conflict', 'Outing ID already belongs to another account', { outingId: body.id }) + } + const persistedCreatedAt = existing?.createdAt ?? body.createdAt - const notes = body.notes ?? '' - const stateProvince = body.stateProvince?.trim() || null - const countryCode = normalizeCountryCode(body.countryCode, stateProvince || undefined) - const protocol = body.protocol?.trim() || null - const numberObservers = - typeof body.numberObservers === 'number' && Number.isFinite(body.numberObservers) - ? Math.max(0, Math.trunc(body.numberObservers)) - : null - const allObsReported = typeof body.allObsReported === 'boolean' ? (body.allObsReported ? 1 : 0) : null - const effortDistanceMiles = - typeof body.effortDistanceMiles === 'number' && Number.isFinite(body.effortDistanceMiles) - ? body.effortDistanceMiles - : null - const effortAreaAcres = - typeof body.effortAreaAcres === 'number' && Number.isFinite(body.effortAreaAcres) - ? body.effortAreaAcres - : null - const columnNames = await getOutingColumnNames(context.env.DB) - const supportsRegionColumns = columnNames.has('stateProvince') && columnNames.has('countryCode') - const supportsChecklistColumns = - columnNames.has('protocol') && - columnNames.has('numberObservers') && - columnNames.has('allObsReported') && - columnNames.has('effortDistanceMiles') && - columnNames.has('effortAreaAcres') + const notes = body.notes ?? '' + const stateProvince = body.stateProvince?.trim() || null + const countryCode = normalizeCountryCode(body.countryCode, stateProvince || undefined) + const protocol = body.protocol?.trim() || null + const numberObservers = + typeof body.numberObservers === 'number' && Number.isFinite(body.numberObservers) + ? Math.max(0, Math.trunc(body.numberObservers)) + : null + const allObsReported = typeof body.allObsReported === 'boolean' ? (body.allObsReported ? 1 : 0) : null + const effortDistanceMiles = + typeof body.effortDistanceMiles === 'number' && Number.isFinite(body.effortDistanceMiles) + ? body.effortDistanceMiles + : null + const effortAreaAcres = + typeof body.effortAreaAcres === 'number' && Number.isFinite(body.effortAreaAcres) + ? body.effortAreaAcres + : null + const columnNames = await getOutingColumnNames(context.env.DB) + const supportsRegionColumns = columnNames.has('stateProvince') && columnNames.has('countryCode') + const supportsChecklistColumns = + columnNames.has('protocol') && + columnNames.has('numberObservers') && + columnNames.has('allObsReported') && + columnNames.has('effortDistanceMiles') && + columnNames.has('effortAreaAcres') - if (supportsRegionColumns && supportsChecklistColumns) { - await context.env.DB.prepare( - `INSERT INTO outing (id, userId, startTime, endTime, locationName, defaultLocationName, lat, lon, stateProvince, countryCode, protocol, numberObservers, allObsReported, effortDistanceMiles, effortAreaAcres, notes, createdAt) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)` - ) - .bind( - body.id, - userId, - body.startTime, - body.endTime, - body.locationName, - body.defaultLocationName ?? null, - body.lat ?? null, - body.lon ?? null, - stateProvince, - countryCode, - protocol, - numberObservers, - allObsReported, - effortDistanceMiles, - effortAreaAcres, - notes, - body.createdAt + if (supportsRegionColumns && supportsChecklistColumns) { + await context.env.DB.prepare( + `INSERT INTO outing (id, userId, startTime, endTime, locationName, defaultLocationName, lat, lon, stateProvince, countryCode, protocol, numberObservers, allObsReported, effortDistanceMiles, effortAreaAcres, notes, createdAt) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17) + ON CONFLICT(id) DO UPDATE SET + startTime = excluded.startTime, endTime = excluded.endTime, + locationName = excluded.locationName, defaultLocationName = excluded.defaultLocationName, + lat = excluded.lat, lon = excluded.lon, stateProvince = excluded.stateProvince, + countryCode = excluded.countryCode, protocol = excluded.protocol, + numberObservers = excluded.numberObservers, allObsReported = excluded.allObsReported, + effortDistanceMiles = excluded.effortDistanceMiles, effortAreaAcres = excluded.effortAreaAcres, + notes = excluded.notes + WHERE outing.userId = excluded.userId` ) - .run() - } else if (supportsRegionColumns) { - await context.env.DB.prepare( - `INSERT INTO outing (id, userId, startTime, endTime, locationName, defaultLocationName, lat, lon, stateProvince, countryCode, notes, createdAt) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)` - ) - .bind( - body.id, - userId, - body.startTime, - body.endTime, - body.locationName, - body.defaultLocationName ?? null, - body.lat ?? null, - body.lon ?? null, - stateProvince, - countryCode, - notes, - body.createdAt + .bind( + body.id, + userId, + body.startTime, + body.endTime, + body.locationName, + body.defaultLocationName ?? null, + body.lat ?? null, + body.lon ?? null, + stateProvince, + countryCode, + protocol, + numberObservers, + allObsReported, + effortDistanceMiles, + effortAreaAcres, + notes, + body.createdAt + ) + .run() + } else if (supportsRegionColumns) { + await context.env.DB.prepare( + `INSERT INTO outing (id, userId, startTime, endTime, locationName, defaultLocationName, lat, lon, stateProvince, countryCode, notes, createdAt) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ON CONFLICT(id) DO UPDATE SET + startTime = excluded.startTime, endTime = excluded.endTime, + locationName = excluded.locationName, defaultLocationName = excluded.defaultLocationName, + lat = excluded.lat, lon = excluded.lon, stateProvince = excluded.stateProvince, + countryCode = excluded.countryCode, notes = excluded.notes + WHERE outing.userId = excluded.userId` ) - .run() - } else { - await context.env.DB.prepare( - `INSERT INTO outing (id, userId, startTime, endTime, locationName, defaultLocationName, lat, lon, notes, createdAt) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)` - ) - .bind( - body.id, - userId, - body.startTime, - body.endTime, - body.locationName, - body.defaultLocationName ?? null, - body.lat ?? null, - body.lon ?? null, - notes, - body.createdAt + .bind( + body.id, + userId, + body.startTime, + body.endTime, + body.locationName, + body.defaultLocationName ?? null, + body.lat ?? null, + body.lon ?? null, + stateProvince, + countryCode, + notes, + body.createdAt + ) + .run() + } else { + await context.env.DB.prepare( + `INSERT INTO outing (id, userId, startTime, endTime, locationName, defaultLocationName, lat, lon, notes, createdAt) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(id) DO UPDATE SET + startTime = excluded.startTime, endTime = excluded.endTime, + locationName = excluded.locationName, defaultLocationName = excluded.defaultLocationName, + lat = excluded.lat, lon = excluded.lon, notes = excluded.notes + WHERE outing.userId = excluded.userId` ) - .run() - } + .bind( + body.id, + userId, + body.startTime, + body.endTime, + body.locationName, + body.defaultLocationName ?? null, + body.lat ?? null, + body.lon ?? null, + notes, + body.createdAt + ) + .run() + } - const scopedRoute = createRouteResponder(route.log?.withResourceId(`outings/${body.id}`), 'data/outings/write', 'Application') - scopedRoute.debug(`Created outing '${body.locationName}'`, { outingId: body.id, locationName: body.locationName }) + const scopedRoute = createRouteResponder(route.log?.withResourceId(`outings/${body.id}`), 'data/outings/write', 'Application') + scopedRoute.debug('Created outing', { outingId: body.id }) - return Response.json({ - id: body.id, - userId, - startTime: body.startTime, - endTime: body.endTime, - locationName: body.locationName, - defaultLocationName: body.defaultLocationName, - lat: body.lat, - lon: body.lon, - stateProvince: supportsRegionColumns ? (stateProvince ?? undefined) : undefined, - countryCode: supportsRegionColumns ? (countryCode ?? undefined) : undefined, - protocol: supportsChecklistColumns ? (protocol ?? undefined) : undefined, - numberObservers: supportsChecklistColumns ? (numberObservers ?? undefined) : undefined, - allObsReported: - supportsChecklistColumns && typeof body.allObsReported === 'boolean' ? body.allObsReported : undefined, - effortDistanceMiles: supportsChecklistColumns ? (effortDistanceMiles ?? undefined) : undefined, - effortAreaAcres: supportsChecklistColumns ? (effortAreaAcres ?? undefined) : undefined, - notes, - createdAt: body.createdAt, - }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Outing creation failed: ${message}`, { error: message, outingId: body.id }) + return Response.json({ + id: body.id, + userId, + startTime: body.startTime, + endTime: body.endTime, + locationName: body.locationName, + defaultLocationName: body.defaultLocationName, + lat: body.lat, + lon: body.lon, + stateProvince: supportsRegionColumns ? (stateProvince ?? undefined) : undefined, + countryCode: supportsRegionColumns ? (countryCode ?? undefined) : undefined, + protocol: supportsChecklistColumns ? (protocol ?? undefined) : undefined, + numberObservers: supportsChecklistColumns ? (numberObservers ?? undefined) : undefined, + allObsReported: + supportsChecklistColumns && typeof body.allObsReported === 'boolean' ? body.allObsReported : undefined, + effortDistanceMiles: supportsChecklistColumns ? (effortDistanceMiles ?? undefined) : undefined, + effortAreaAcres: supportsChecklistColumns ? (effortAreaAcres ?? undefined) : undefined, + notes, + createdAt: persistedCreatedAt, + }) + } catch { + return route.fail(500, 'Internal server error', 'Outing creation failed; inspect the trace and database operation', { outingId: body.id }) } } diff --git a/functions/api/data/outings/[id].ts b/functions/api/data/outings/[id].ts index 7bfe5c12..1293deff 100644 --- a/functions/api/data/outings/[id].ts +++ b/functions/api/data/outings/[id].ts @@ -1,4 +1,4 @@ -import { computeDex } from '../../../lib/dex-query' +import { computeDex, enrichDexEntries } from '../../../lib/dex-query' import { getOutingColumnNames } from '../../../lib/schema' import { createRouteResponder } from '../../../lib/log' @@ -182,9 +182,8 @@ export const onRequestPatch: PagesFunction = async context => { effortDistanceMiles: outing.effortDistanceMiles ?? undefined, effortAreaAcres: outing.effortAreaAcres ?? undefined, }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Outing ${outingId} patch failed: ${message}`, { outingId, error: message }) + } catch { + return route.fail(500, 'Internal server error', 'Outing patch failed; inspect the trace and database operation', { outingId }) } } @@ -214,9 +213,8 @@ export const onRequestDelete: PagesFunction = async context => { route.debug(`Deleted outing ${outingId}`, { outingId }) const dexUpdates = await computeDex(context.env.DB, userId) - return Response.json({ dexUpdates }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Outing ${outingId} delete failed: ${message}`, { outingId, error: message }) + return Response.json({ dexUpdates: enrichDexEntries(dexUpdates) }) + } catch { + return route.fail(500, 'Internal server error', 'Outing deletion failed; inspect the trace and database operation', { outingId }) } } diff --git a/functions/api/data/photos.ts b/functions/api/data/photos.ts index e2463325..b100cf2a 100644 --- a/functions/api/data/photos.ts +++ b/functions/api/data/photos.ts @@ -1,4 +1,5 @@ import { createRouteResponder } from '../../lib/log' +import { queryInChunks } from '../../lib/d1-chunk' type CreatePhotoInput = { id: string @@ -29,13 +30,45 @@ async function hasOwnedOutings(db: D1Database, userId: string, outingIds: string const uniqueOutingIds = Array.from(new Set(outingIds)) if (uniqueOutingIds.length === 0) return true - const placeholders = uniqueOutingIds.map(() => '?').join(', ') - const result = await db - .prepare(`SELECT id FROM outing WHERE userId = ? AND id IN (${placeholders})`) - .bind(userId, ...uniqueOutingIds) - .all<{ id: string }>() + const rows = await queryInChunks(uniqueOutingIds, async (chunk, placeholders) => { + const result = await db + .prepare(`SELECT id FROM outing WHERE userId = ? AND id IN (${placeholders})`) + .bind(userId, ...chunk) + .all<{ id: string }>() + return result.results + }) - return result.results.length === uniqueOutingIds.length + return rows.length === uniqueOutingIds.length +} + +async function hasCompatiblePhotoIds( + db: D1Database, + userId: string, + photos: CreatePhotoInput[], + requireAll = false, +): Promise { + const ids = [...new Set(photos.map(photo => photo.id))] + if (ids.length === 0) return true + const expectedOutings = new Map(photos.map(photo => [photo.id, photo.outingId])) + const existing = await queryInChunks(ids, async (chunk, placeholders) => { + const result = await db + .prepare(`SELECT id, userId, outingId FROM photo WHERE id IN (${placeholders})`) + .bind(...chunk) + .all<{ id: string; userId: string; outingId: string }>() + return result.results + }) + return (!requireAll || existing.length === ids.length) && + existing.every(photo => photo.userId === userId && photo.outingId === expectedOutings.get(photo.id)) +} + +function hasConflictingPhotoIds(photos: CreatePhotoInput[]): boolean { + const outingsById = new Map() + for (const photo of photos) { + const existing = outingsById.get(photo.id) + if (existing && existing !== photo.outingId) return true + outingsById.set(photo.id, photo.outingId) + } + return outingsById.size !== photos.length } export const onRequestPost: PagesFunction = async context => { @@ -59,6 +92,9 @@ export const onRequestPost: PagesFunction = async context => { if (body.length === 0) { return Response.json([]) } + if (hasConflictingPhotoIds(body)) { + return route.fail(400, 'Duplicate photo IDs', 'Photo payload must contain unique IDs', { count: body.length }) + } try { const allOwned = await hasOwnedOutings( @@ -70,11 +106,21 @@ export const onRequestPost: PagesFunction = async context => { const failOutingIds = [...new Set(body.map(p => p.outingId))] return route.fail(400, 'Invalid outing reference', `One or more outing IDs are not owned by user or do not exist`, { outingIds: failOutingIds }) } + if (!await hasCompatiblePhotoIds(context.env.DB, userId, body)) { + return route.fail(409, 'Photo ID conflict', 'One or more photo IDs already belong to another account or outing', { count: body.length }) + } const statements = body.map(photo => context.env.DB.prepare( `INSERT INTO photo (id, outingId, userId, dataUrl, thumbnail, exifTime, gpsLat, gpsLon, fileHash, fileName) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)` + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(id) DO UPDATE SET + exifTime = excluded.exifTime, + gpsLat = excluded.gpsLat, + gpsLon = excluded.gpsLon, + fileHash = excluded.fileHash, + fileName = excluded.fileName + WHERE photo.userId = excluded.userId AND photo.outingId = excluded.outingId` ).bind( photo.id, photo.outingId, @@ -90,21 +136,25 @@ export const onRequestPost: PagesFunction = async context => { ) await context.env.DB.batch(statements) + if (!await hasCompatiblePhotoIds(context.env.DB, userId, body, true)) { + return route.fail(409, 'Photo ID conflict', 'One or more photo IDs were concurrently claimed by another account or outing', { count: body.length }) + } const outingIds = [...new Set(body.map(p => p.outingId))] const scopedRoute = outingIds.length === 1 ? createRouteResponder(route.log?.withResourceId(`outings/${outingIds[0]}/photos`), 'data/photos/write', 'Application') : route - scopedRoute.debug(`Inserted ${body.length} photos into ${outingIds.length} outings`, { photoCount: body.length, outingCount: outingIds.length }) + scopedRoute.debug(`Persisted ${body.length} photos into ${outingIds.length} outings`, { photoCount: body.length, outingCount: outingIds.length }) return Response.json( body.map(photo => ({ ...photo, + dataUrl: '', + thumbnail: '', exifTime: photo.exifTime || undefined, gps: photo.gps ? { lat: photo.gps.lat, lon: photo.gps.lon } : undefined, })) ) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Photo insert failed: ${message}`, { error: message, count: body.length }) + } catch { + return route.fail(500, 'Internal server error', 'Photo persistence failed; inspect the trace and database operation', { count: body.length }) } } diff --git a/functions/api/export/dex.ts b/functions/api/export/dex.ts index 21e8624d..29ea3960 100644 --- a/functions/api/export/dex.ts +++ b/functions/api/export/dex.ts @@ -21,8 +21,7 @@ export const onRequestGet: PagesFunction = async context => { 'cache-control': 'no-store', }, }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `Dex export failed: ${message}`, { error: message }) + } catch { + return route.fail(500, 'Internal server error', 'Dex export failed; inspect the trace and database query') } } diff --git a/functions/api/export/outing/[id].ts b/functions/api/export/outing/[id].ts index 4ef32e9b..84b73cc6 100644 --- a/functions/api/export/outing/[id].ts +++ b/functions/api/export/outing/[id].ts @@ -99,8 +99,7 @@ export const onRequestGet: PagesFunction = async context => { 'cache-control': 'no-store', }, }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Export failed', `Outing ${outingId} export failed: ${message}`, { outingId, error: message }) + } catch { + return route.fail(500, 'Export failed', 'Outing export failed; inspect the trace and database query', { outingId }) } } diff --git a/functions/api/export/sightings.ts b/functions/api/export/sightings.ts index 332d9e29..5afd5513 100644 --- a/functions/api/export/sightings.ts +++ b/functions/api/export/sightings.ts @@ -142,8 +142,7 @@ export const onRequestGet: PagesFunction = async context => { 'cache-control': 'no-store', }, }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Export failed', `Sightings export failed: ${message}`, { error: message }) + } catch { + return route.fail(500, 'Export failed', 'Sightings export failed; inspect the trace and database query') } } diff --git a/functions/api/health.ts b/functions/api/health.ts index 87953442..f651ad97 100644 --- a/functions/api/health.ts +++ b/functions/api/health.ts @@ -9,9 +9,8 @@ export const onRequestGet: PagesFunction = async (context) => { } route.fail(503, 'D1 health check returned unexpected result', 'D1 health check returned an unexpected result; the database may be in a degraded state') return Response.json({ status: 'degraded', db: 'unexpected' }, { status: 503 }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - route.fail(503, 'D1 health check failed', `D1 health check failed: ${message}`) + } catch { + route.fail(503, 'D1 health check failed', 'D1 health check failed; inspect the database binding and trace') return Response.json({ status: 'degraded', db: 'error' }, { status: 503 }) } } diff --git a/functions/api/identify-bird.ts b/functions/api/identify-bird.ts index 83bcf170..4da46345 100644 --- a/functions/api/identify-bird.ts +++ b/functions/api/identify-bird.ts @@ -144,14 +144,13 @@ export const onRequestPost: PagesFunction = async context => { return Response.json(result) } catch (error) { if (error instanceof RateLimitError) { - return route.failWithHeaders(error.status, error.message, { 'Retry-After': String(error.retryAfterSeconds) }, `Bird identification rate-limited: ${error.message}; retry after ${error.retryAfterSeconds}s`, { retryAfterSeconds: error.retryAfterSeconds }) + return route.failWithHeaders(error.status, error.message, { 'Retry-After': String(error.retryAfterSeconds) }, 'Bird identification request was rate-limited', { retryAfterSeconds: error.retryAfterSeconds }) } if (error instanceof HttpError) { - return route.fail(error.status, error.message, `Bird identification failed: ${error.message}`) + return route.fail(error.status, error.message, `Bird identification failed with HTTP ${error.status}`) } - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'An unexpected error occurred during bird identification', `Bird identification failed unexpectedly: ${message}`, { error: message }) + return route.fail(500, 'An unexpected error occurred during bird identification', 'Bird identification failed unexpectedly; inspect the trace and upstream model request') } } diff --git a/functions/api/import/ebird-csv.ts b/functions/api/import/ebird-csv.ts index 11c5a6e7..71072d37 100644 --- a/functions/api/import/ebird-csv.ts +++ b/functions/api/import/ebird-csv.ts @@ -63,8 +63,7 @@ export const onRequestPost: PagesFunction = async context => { } return Response.json({ previews: previewsWithIds, summary }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `eBird CSV import failed: ${message}`, { error: message, fileSize: file.size }) + } catch { + return route.fail(500, 'Internal server error', 'eBird CSV import failed; inspect the trace and parser/database operation', { fileSize: file.size }) } } diff --git a/functions/api/import/ebird-csv/confirm.ts b/functions/api/import/ebird-csv/confirm.ts index 152ad055..eb1f5dd1 100644 --- a/functions/api/import/ebird-csv/confirm.ts +++ b/functions/api/import/ebird-csv/confirm.ts @@ -1,4 +1,4 @@ -import { computeDex } from '../../../lib/dex-query' +import { computeDex, enrichDexEntries } from '../../../lib/dex-query' import { groupPreviewsIntoOutings, type ImportPreview } from '../../../lib/ebird' import { getOutingColumnNames, hasObservationColumn } from '../../../lib/schema' import { createRouteResponder } from '../../../lib/log' @@ -55,7 +55,10 @@ export const onRequestPost: PagesFunction = async context => { if (selectedPreviews.length === 0) { const dexUpdates = await computeDex(context.env.DB, userId) - return Response.json({ imported: { outings: 0, observations: 0, newSpecies: 0 }, dexUpdates }) + return Response.json({ + imported: { outings: 0, observations: 0, newSpecies: 0 }, + dexUpdates: enrichDexEntries(dexUpdates), + }) } // Snapshot species already in the user's dex before inserting @@ -201,14 +204,9 @@ export const onRequestPost: PagesFunction = async context => { observations: observations.length, newSpecies, }, - dexUpdates: dexUpdates.map(row => ({ - ...row, - addedDate: row.addedDate || undefined, - bestPhotoId: row.bestPhotoId || undefined, - })), + dexUpdates: enrichDexEntries(dexUpdates), }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return route.fail(500, 'Internal server error', `eBird import confirm failed: ${message}`, { error: message, previewCount: body.previewIds.length }) + } catch { + return route.fail(500, 'Internal server error', 'eBird import confirmation failed; inspect the trace and database batch', { previewCount: body.previewIds.length }) } } diff --git a/functions/api/species/ebird-code.ts b/functions/api/species/ebird-code.ts index 9d946b23..96c6a4a1 100644 --- a/functions/api/species/ebird-code.ts +++ b/functions/api/species/ebird-code.ts @@ -1,9 +1,11 @@ import { getEbirdCode } from '../../lib/taxonomy' +import { createRouteResponder } from '../../lib/log' export const onRequestGet: PagesFunction = async context => { + const route = createRouteResponder(context.data.log, 'species/ebirdCode/read', 'Application') const userId = (context.data as { user?: { id?: string } }).user?.id if (!userId) { - return new Response('Unauthorized', { status: 401 }) + return route.fail(401, 'Unauthorized', 'eBird code lookup requires an authenticated session') } const name = new URL(context.request.url).searchParams.get('name') ?? '' diff --git a/functions/api/species/search.ts b/functions/api/species/search.ts index 57de14f1..cb3566b8 100644 --- a/functions/api/species/search.ts +++ b/functions/api/species/search.ts @@ -1,4 +1,5 @@ import { searchSpecies } from '../../lib/taxonomy' +import { createRouteResponder } from '../../lib/log' function parseLimit(value: string | null): number { if (!value) return 8 @@ -8,9 +9,10 @@ function parseLimit(value: string | null): number { } export const onRequestGet: PagesFunction = async context => { + const route = createRouteResponder(context.data.log, 'species/search/read', 'Application') const userId = (context.data as { user?: { id?: string } }).user?.id if (!userId) { - return new Response('Unauthorized', { status: 401 }) + return route.fail(401, 'Unauthorized', 'Species search requires an authenticated session') } const query = new URL(context.request.url).searchParams.get('q') ?? '' diff --git a/functions/api/species/wiki-title.ts b/functions/api/species/wiki-title.ts index db8884ed..21a03a7b 100644 --- a/functions/api/species/wiki-title.ts +++ b/functions/api/species/wiki-title.ts @@ -1,6 +1,8 @@ import { getWikiMetadata } from '../../lib/taxonomy' +import { createRouteResponder } from '../../lib/log' export const onRequestGet: PagesFunction = async context => { + const route = createRouteResponder(context.data.log, 'species/wikiTitle/read', 'Application') const name = new URL(context.request.url).searchParams.get('name') if (!name?.trim()) { @@ -8,6 +10,7 @@ export const onRequestGet: PagesFunction = async context => { } const metadata = getWikiMetadata(name) + route.debug('Resolved wiki metadata', { hasWikiTitle: !!metadata.wikiTitle }) return Response.json({ wikiTitle: metadata.wikiTitle || null, diff --git a/functions/lib/auth.ts b/functions/lib/auth.ts index d6c3da15..d655f287 100644 --- a/functions/lib/auth.ts +++ b/functions/lib/auth.ts @@ -18,7 +18,6 @@ type SocialProviderConfig = { appBundleIdentifier?: string } -const AUTH_DEBUG_LOGGED_KEY = '__wingdexAuthDebugLogged__' function isLoopbackOrigin(value: string | null): value is string { if (!value) return false @@ -176,20 +175,6 @@ export function createAuth(env: Env, options: CreateAuthOptions = {}) { return baseURL })() - if (isLoopbackOrigin(baseURL)) { - const globalRef = globalThis as typeof globalThis & Record - if (globalRef[AUTH_DEBUG_LOGGED_KEY] !== true) { - globalRef[AUTH_DEBUG_LOGGED_KEY] = true - console.info('[auth:dev] resolved origins', { - baseURL, - requestOrigin, - headerOrigin, - passkeyOrigin, - trustedOrigins: Array.from(trustedOrigins), - }) - } - } - const socialProviders: Record = {} if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) { socialProviders.github = { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET } diff --git a/functions/lib/d1-chunk.ts b/functions/lib/d1-chunk.ts new file mode 100644 index 00000000..825aef79 --- /dev/null +++ b/functions/lib/d1-chunk.ts @@ -0,0 +1,33 @@ +// D1 allows at most 100 bound parameters per query. Lookups that bind one +// parameter per ID (plus any fixed leading params such as userId) must split +// large user-controlled ID lists into chunks to avoid exceeding that limit. +// https://developers.cloudflare.com/d1/platform/limits/ + +// Keep a safe margin below D1's 100-parameter ceiling to leave room for a +// leading fixed parameter (e.g. userId) in the same query. +const MAX_IDS_PER_QUERY = 90 + +export function chunkIds(ids: T[], size: number = MAX_IDS_PER_QUERY): T[][] { + if (size < 1) throw new Error('chunk size must be >= 1') + const chunks: T[][] = [] + for (let i = 0; i < ids.length; i += size) { + chunks.push(ids.slice(i, i + size)) + } + return chunks +} + +// Runs an IN(...) lookup over `ids` in D1-safe chunks and concatenates the +// per-chunk rows. `runChunk` receives a chunk plus its comma-joined `?` +// placeholders and returns that chunk's rows. +export async function queryInChunks( + ids: Id[], + runChunk: (chunk: Id[], placeholders: string) => Promise, + size: number = MAX_IDS_PER_QUERY, +): Promise { + const rows: Row[] = [] + for (const chunk of chunkIds(ids, size)) { + const placeholders = chunk.map(() => '?').join(', ') + rows.push(...(await runChunk(chunk, placeholders))) + } + return rows +} diff --git a/functions/lib/dex-query.ts b/functions/lib/dex-query.ts index b5b321f4..0df3dd57 100644 --- a/functions/lib/dex-query.ts +++ b/functions/lib/dex-query.ts @@ -1,4 +1,6 @@ -type DexRow = { +import { getWikiMetadata } from './taxonomy' + +export type DexRow = { speciesName: string firstSeenDate: string lastSeenDate: string @@ -40,3 +42,16 @@ export async function computeDex(db: DexQueryDB, userId: string): Promise() return result.results } + +export function enrichDexEntries(rows: DexRow[]) { + return rows.map(row => { + const { wikiTitle, thumbnailUrl } = getWikiMetadata(row.speciesName) + return { + ...row, + addedDate: row.addedDate || undefined, + bestPhotoId: row.bestPhotoId || undefined, + wikiTitle, + thumbnailUrl, + } + }) +} diff --git a/ios/WingDex.xcodeproj/project.pbxproj b/ios/WingDex.xcodeproj/project.pbxproj index 157531fd..123e0356 100644 --- a/ios/WingDex.xcodeproj/project.pbxproj +++ b/ios/WingDex.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 0FED48A726585A89C3989A51 /* WingDexApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73B4D479201C022372C3F4BF /* WingDexApp.swift */; }; 1587B24CB8AEF6D2C3D0DCEB /* EBirdImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A167BA0320720274AA15EF34 /* EBirdImportView.swift */; }; 1735984485D0D573F5B44CC5 /* collage13.jpg in Resources */ = {isa = PBXBuildFile; fileRef = AE7506A9CCCD1EF7E59111BE /* collage13.jpg */; }; + 178DBB3AF05F16FF018BA97B /* NativeErrorPreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53F7623102ABB493E2971FA /* NativeErrorPreviews.swift */; }; 1ABC63F63D0C5C701EA041F3 /* collage17.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 92009ED89B0B508740436CC5 /* collage17.jpg */; }; 1B3D5823766242A3608DE553 /* collage2.jpg in Resources */ = {isa = PBXBuildFile; fileRef = B8A7A81D10BED074ED746634 /* collage2.jpg */; }; 223DAEB5BCD5CAC52065CF13 /* taxonomy.json in Resources */ = {isa = PBXBuildFile; fileRef = F930B7A16F2B5C40B971D674 /* taxonomy.json */; }; @@ -24,6 +25,7 @@ 27F3FC5181B71ADB687A56CC /* CropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E26C4686392C96AAB515199 /* CropView.swift */; }; 28C700594AF36399A460FAAB /* CollageImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A1D850B642C51F0AEA560F3 /* CollageImageCache.swift */; }; 2CDDDF5ED153526ADF124174 /* collage21.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 7A292B9762D444579B157E2C /* collage21.jpg */; }; + 32A90BB7899AF9413E1466ED /* CelebrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BDBF72550893F931771EA36 /* CelebrationTests.swift */; }; 3765CB91633BD6B909098629 /* AddPhotosViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DFE3278988BC58601C97F3 /* AddPhotosViewModel.swift */; }; 3AD082AB856689D48C4E9C72 /* DataManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA379E90DE3BD99B508BCD7 /* DataManagementView.swift */; }; 3E0B15C139B971B778758337 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DF3BE4EAF4D781A2029593E /* DataStore.swift */; }; @@ -38,7 +40,9 @@ 61E5088EFD9DB2B036AA1F61 /* OutingReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A830FC9081930F984391A2BC /* OutingReviewView.swift */; }; 6541BF20FA6B0465C6AA8204 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584DE950D0BBC4D9543B7EB4 /* HomeView.swift */; }; 6A3E3A8BE6F89235D4E0ED32 /* collage1.jpg in Resources */ = {isa = PBXBuildFile; fileRef = BC6ADC80E561240BA8BF7D7E /* collage1.jpg */; }; + 701F62DDB95D5BDF65E3E23D /* CelebrationOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1934AFCA827070FC4A689F84 /* CelebrationOverlay.swift */; }; 71AE086D49EDD88D2294F2E1 /* demo-ebird-import.csv in Resources */ = {isa = PBXBuildFile; fileRef = 071E15B7BF7B6D9EA12130BA /* demo-ebird-import.csv */; }; + 735564E615ECED94D844C13B /* TaxonomyOrderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C56DDD787BF000019EB3051 /* TaxonomyOrderTests.swift */; }; 76D79E728919533E7EC625BA /* wingdex.bird.fill.svg in Resources */ = {isa = PBXBuildFile; fileRef = B594465F1456F0C7DB88CEDC /* wingdex.bird.fill.svg */; }; 78A345C5C3608103CBE304D2 /* AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55E48905A381B4AAD9D6D13 /* AuthenticatedRequest.swift */; }; 7D8B718A1CCBEBD903238420 /* PhotoService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5873328D24830AA514049E5C /* PhotoService.swift */; }; @@ -75,6 +79,7 @@ EE234F9D0E5A8EC5DDC52616 /* DataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBE1598DFE8F27ED8313060 /* DataService.swift */; }; F328447561BB5DDA17308FD4 /* Data+Base64URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 839C24B2C567AC5C75E3844C /* Data+Base64URL.swift */; }; F6615E3D24C9B015D69520FA /* PasskeyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55734005BE9F01B6719A826 /* PasskeyService.swift */; }; + F7BB21E4149CCE3421A4CD39 /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989AEA195C999B4857206090 /* AppError.swift */; }; F8B6EC497AAFE5B360FEB1C4 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E01D6CCC5A4C9255372AFD03 /* Preview Assets.xcassets */; }; F9D9676BF78FC086E34593E6 /* collage24.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 487950E4852A38D4FFA3B405 /* collage24.jpg */; }; FBEFC9BF3A67B58D4A6F4330 /* collage23.jpg in Resources */ = {isa = PBXBuildFile; fileRef = A622289860EDC9BA77952568 /* collage23.jpg */; }; @@ -103,6 +108,7 @@ 1758F97178B6D85D64F1363D /* collage19.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = collage19.jpg; sourceTree = ""; }; 181CAF182184C0BF767EC8E4 /* collage16.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = collage16.jpg; sourceTree = ""; }; 190E573E0224A499A6CEB58C /* AddPhotosFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPhotosFlow.swift; sourceTree = ""; }; + 1934AFCA827070FC4A689F84 /* CelebrationOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CelebrationOverlay.swift; sourceTree = ""; }; 19D47136847CF32C6251A4BD /* FunNames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunNames.swift; sourceTree = ""; }; 1A4472F76A8259376EBEA738 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 1AAEF45D13FB117DED5ED47F /* WingDex.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = WingDex.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -117,6 +123,8 @@ 452C55049EB9AB7E09E62CA9 /* FunNamesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunNamesTests.swift; sourceTree = ""; }; 487950E4852A38D4FFA3B405 /* collage24.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = collage24.jpg; sourceTree = ""; }; 494CD419AD323D541E04D2AB /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; + 4BDBF72550893F931771EA36 /* CelebrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CelebrationTests.swift; sourceTree = ""; }; + 4C56DDD787BF000019EB3051 /* TaxonomyOrderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaxonomyOrderTests.swift; sourceTree = ""; }; 557A070AA631D1A44FE4F407 /* GitInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitInfo.swift; sourceTree = ""; }; 584DE950D0BBC4D9543B7EB4 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 5873328D24830AA514049E5C /* PhotoService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoService.swift; sourceTree = ""; }; @@ -136,11 +144,13 @@ 8DF3BE4EAF4D781A2029593E /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = ""; }; 8E0885077F6EE158317DCAC6 /* AppIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconView.swift; sourceTree = ""; }; 92009ED89B0B508740436CC5 /* collage17.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = collage17.jpg; sourceTree = ""; }; + 989AEA195C999B4857206090 /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; 9986F927C0C622E7A4DE4C2E /* AppModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModels.swift; sourceTree = ""; }; 99DD81C22AA9F61ABDD60B50 /* collage11.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = collage11.jpg; sourceTree = ""; }; 9E26C4686392C96AAB515199 /* CropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CropView.swift; sourceTree = ""; }; A167BA0320720274AA15EF34 /* EBirdImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBirdImportView.swift; sourceTree = ""; }; A4E7F8433923067E08E2E6C2 /* WingDex.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WingDex.entitlements; sourceTree = ""; }; + A53F7623102ABB493E2971FA /* NativeErrorPreviews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeErrorPreviews.swift; sourceTree = ""; }; A622289860EDC9BA77952568 /* collage23.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = collage23.jpg; sourceTree = ""; }; A830FC9081930F984391A2BC /* OutingReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutingReviewView.swift; sourceTree = ""; }; AE7506A9CCCD1EF7E59111BE /* collage13.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = collage13.jpg; sourceTree = ""; }; @@ -265,9 +275,11 @@ isa = PBXGroup; children = ( 8E0885077F6EE158317DCAC6 /* AppIconView.swift */, + 1934AFCA827070FC4A689F84 /* CelebrationOverlay.swift */, 8BA379E90DE3BD99B508BCD7 /* DataManagementView.swift */, A167BA0320720274AA15EF34 /* EBirdImportView.swift */, 584DE950D0BBC4D9543B7EB4 /* HomeView.swift */, + A53F7623102ABB493E2971FA /* NativeErrorPreviews.swift */, E6F5DCDBE61DDC1D237FD032 /* OutingDetailView.swift */, BB01BEA15BC45041F637C903 /* OutingsView.swift */, 2C367F98A44D6C24B18F1301 /* PasskeyManagementView.swift */, @@ -326,7 +338,9 @@ children = ( 40E4A1B21654CC24CA891696 /* AuthIntegrationTests.swift */, DE1613DCCF3480F9644D0D29 /* AuthServiceTests.swift */, + 4BDBF72550893F931771EA36 /* CelebrationTests.swift */, 452C55049EB9AB7E09E62CA9 /* FunNamesTests.swift */, + 4C56DDD787BF000019EB3051 /* TaxonomyOrderTests.swift */, ); path = WingDexTests; sourceTree = ""; @@ -363,6 +377,7 @@ E6423F10CAA29DF0E53067C9 /* Models */ = { isa = PBXGroup; children = ( + 989AEA195C999B4857206090 /* AppError.swift */, 9986F927C0C622E7A4DE4C2E /* AppModels.swift */, ); path = Models; @@ -516,10 +531,12 @@ CAF5CA6AE5E559BB812F83AC /* Assets.xcassets in Resources */, files = ( ABA7A9183EF63B93C1241B34 /* AddPhotosFlow.swift in Sources */, 3765CB91633BD6B909098629 /* AddPhotosViewModel.swift in Sources */, + F7BB21E4149CCE3421A4CD39 /* AppError.swift in Sources */, CCB60C37FEB5B3C7781CF308 /* AppIconView.swift in Sources */, 0C26BD71C3D1128D41123339 /* AppModels.swift in Sources */, 4BF3798F0ADD7B34948B1C65 /* AuthService.swift in Sources */, 78A345C5C3608103CBE304D2 /* AuthenticatedRequest.swift in Sources */, + 701F62DDB95D5BDF65E3E23D /* CelebrationOverlay.swift in Sources */, 28C700594AF36399A460FAAB /* CollageImageCache.swift in Sources */, 2300C447E8E145BFE9E12FA5 /* Config.swift in Sources */, FE52C5B543CE063EF47FEFF5 /* CropService.swift in Sources */, @@ -533,6 +550,7 @@ CAF5CA6AE5E559BB812F83AC /* Assets.xcassets in Resources */, 48572F12742996B68226EECF /* FunNames.swift in Sources */, 085CA0BF32A695F766CBB46B /* GitInfo.swift in Sources */, 6541BF20FA6B0465C6AA8204 /* HomeView.swift in Sources */, + 178DBB3AF05F16FF018BA97B /* NativeErrorPreviews.swift in Sources */, FD2A7A67F8326BF5BAAF67BE /* OutingDetailView.swift in Sources */, 61E5088EFD9DB2B036AA1F61 /* OutingReviewView.swift in Sources */, C4F77EC5DA4A743530651286 /* OutingsView.swift in Sources */, @@ -558,7 +576,9 @@ CAF5CA6AE5E559BB812F83AC /* Assets.xcassets in Resources */, files = ( 58B69A80EFAF3294811D2CE3 /* AuthIntegrationTests.swift in Sources */, 8EE17A4E87495C877151768B /* AuthServiceTests.swift in Sources */, + 32A90BB7899AF9413E1466ED /* CelebrationTests.swift in Sources */, E2DEFBDD3B36FEB4C7CFCC96 /* FunNamesTests.swift in Sources */, + 735564E615ECED94D844C13B /* TaxonomyOrderTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -658,7 +678,7 @@ CAF5CA6AE5E559BB812F83AC /* Assets.xcassets in Resources */, "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.3.0; + MARKETING_VERSION = 0.5.0; PRODUCT_BUNDLE_IDENTIFIER = app.wingdex; SDKROOT = iphoneos; SWIFT_STRICT_CONCURRENCY = complete; @@ -780,7 +800,7 @@ CAF5CA6AE5E559BB812F83AC /* Assets.xcassets in Resources */, "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.3.0; + MARKETING_VERSION = 0.5.0; PRODUCT_BUNDLE_IDENTIFIER = app.wingdex; SDKROOT = iphoneos; SWIFT_STRICT_CONCURRENCY = complete; diff --git a/ios/WingDex/App/WingDexApp.swift b/ios/WingDex/App/WingDexApp.swift index d36261f8..0ec5359e 100644 --- a/ios/WingDex/App/WingDexApp.swift +++ b/ios/WingDex/App/WingDexApp.swift @@ -32,6 +32,7 @@ struct WingDexApp: App { struct ContentView: View { @Environment(AuthService.self) private var auth @Environment(DataStore.self) private var store + @Environment(\.scenePhase) private var scenePhase @State private var isValidating = true @@ -67,6 +68,15 @@ struct ContentView: View { } .background(Color.pageBg.ignoresSafeArea()) .animation(.easeInOut(duration: 0.25), value: auth.isAuthenticated) + .onChange(of: auth.isAuthenticated) { _, isAuthenticated in + if !isAuthenticated { + store.reset() + } + } + .onChange(of: scenePhase) { _, phase in + guard phase == .active, auth.isAuthenticated, !isValidating else { return } + Task { await auth.validateSession() } + } .task { if auth.isAuthenticated { await auth.validateSession() diff --git a/ios/WingDex/Extensions/DateFormatting.swift b/ios/WingDex/Extensions/DateFormatting.swift index ed6f1030..97f62193 100644 --- a/ios/WingDex/Extensions/DateFormatting.swift +++ b/ios/WingDex/Extensions/DateFormatting.swift @@ -172,23 +172,26 @@ func getScientificName(_ speciesName: String) -> String? { } /// Lowercased common name lookups built from the bundled taxonomy.json in a single pass. -/// Slot [2] -> eBird species code. Slot [5] -> BirdLife DataZone species ID. -private let taxonomyLookups: (ebird: [String: String], birdlife: [String: String]) = { +/// Array index -> taxonomic order. Slot [2] -> eBird code. Slot [5] -> BirdLife ID. +private let taxonomyLookups: (ebird: [String: String], birdlife: [String: String], order: [String: Int]) = { guard let url = Bundle.main.url(forResource: "taxonomy", withExtension: "json"), let data = try? Data(contentsOf: url), let rawEntries = try? JSONSerialization.jsonObject(with: data) as? [[Any]] else { - return ([:], [:]) + return ([:], [:], [:]) } var ebird: [String: String] = [:] var birdlife: [String: String] = [:] + var order: [String: Int] = [:] ebird.reserveCapacity(rawEntries.count) birdlife.reserveCapacity(rawEntries.count) + order.reserveCapacity(rawEntries.count) - for entry in rawEntries { + for (index, entry) in rawEntries.enumerated() { guard let commonName = entry.first as? String else { continue } let key = commonName.lowercased() + order[key] = index if entry.count > 2, let code = entry[2] as? String, !code.isEmpty { ebird[key] = code @@ -198,12 +201,30 @@ private let taxonomyLookups: (ebird: [String: String], birdlife: [String: String } } - return (ebird, birdlife) + return (ebird, birdlife, order) }() private var ebirdCodeLookup: [String: String] { taxonomyLookups.ebird } private var birdlifeIdLookup: [String: String] { taxonomyLookups.birdlife } +/// Return the bundled eBird taxonomy index for sorting, or Int.max when unknown. +func getTaxonomicOrder(_ speciesName: String) -> Int { + let commonName = getDisplayName(speciesName).trimmingCharacters(in: .whitespacesAndNewlines) + return taxonomyLookups.order[commonName.lowercased()] ?? Int.max +} + +/// Compare stored species names by taxonomic sequence, keeping unknown species last. +func taxonomicSpeciesPrecedes(_ lhs: String, _ rhs: String, ascending: Bool) -> Bool { + let lhsOrder = getTaxonomicOrder(lhs) + let rhsOrder = getTaxonomicOrder(rhs) + let lhsKnown = lhsOrder != Int.max + let rhsKnown = rhsOrder != Int.max + + if lhsKnown != rhsKnown { return lhsKnown } + if lhsOrder != rhsOrder { return ascending ? lhsOrder < rhsOrder : lhsOrder > rhsOrder } + return lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending +} + /// Build the eBird species URL for a stored species name. func getEbirdURL(for speciesName: String) -> URL? { let commonName = getDisplayName(speciesName).trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/ios/WingDex/Models/AppError.swift b/ios/WingDex/Models/AppError.swift new file mode 100644 index 00000000..881b2d10 --- /dev/null +++ b/ios/WingDex/Models/AppError.swift @@ -0,0 +1,127 @@ +import AuthenticationServices +import Foundation + +enum AppError: Error, Equatable, Identifiable { + case offline + case timedOut + case sessionExpired + case rateLimited(limit: Int, retryAfter: TimeInterval?) + case passkeyUnavailable + case authenticationFailed + case server + case invalidResponse + case message(String) + + var id: String { message } + + var message: String { + switch self { + case .offline: + "You're offline. Check your connection and try again." + case .timedOut: + "The request timed out. Try again." + case .sessionExpired: + "Your session expired. Please sign in again." + case .rateLimited(let limit, let retryAfter): + if let retryAfter { + "AI identification limit reached (\(limit) requests/day). Try again in \(Self.durationText(retryAfter))." + } else { + "AI identification limit reached (\(limit) requests/day). Try again later." + } + case .passkeyUnavailable: + "Passkeys aren't available for this app or domain." + case .authenticationFailed: + "Authentication failed. Try again." + case .server, .invalidResponse: + "Something went wrong. Try again." + case .message(let message): + message + } + } + + static func map( + _ error: Error, + fallback: String = "Something went wrong. Try again.", + rateLimit: Int? = nil + ) -> AppError? { + if let appError = error as? AppError { + return appError + } + + if let authorizationError = error as? ASAuthorizationError { + switch authorizationError.code { + case .canceled: + return nil + case .notHandled: + return .passkeyUnavailable + default: + return .authenticationFailed + } + } + + if let urlError = error as? URLError { + switch urlError.code { + case .cancelled: + return nil + case .notConnectedToInternet, .networkConnectionLost, .cannotConnectToHost, + .cannotFindHost, .dnsLookupFailed, .internationalRoamingOff: + return .offline + case .timedOut: + return .timedOut + default: + return .message(fallback) + } + } + + if let serviceError = error as? DataServiceError { + switch serviceError { + case .network(let urlError): + return map(urlError, fallback: fallback, rateLimit: rateLimit) + case .invalidResponse: + return .invalidResponse + case .http(let status, let message, let retryAfter): + if status == 401 { return .sessionExpired } + if status == 429 { + if let rateLimit { + return .rateLimited(limit: rateLimit, retryAfter: retryAfter) + } + return .message("Too many requests. Try again later.") + } + if (400...499).contains(status), let message { + return .message(message) + } + return .server + } + } + + if error is DecodingError { + return .invalidResponse + } + if let authError = error as? AuthError { + switch authError { + case .notAuthenticated: + return .sessionExpired + case .oauthFailed: + return .message(fallback) + } + } + if let passkeyError = error as? PasskeyError { + switch passkeyError { + case .authenticationFailed: + return .authenticationFailed + default: + return .message(fallback) + } + } + return .message(fallback) + } + + private static func durationText(_ duration: TimeInterval) -> String { + let seconds = max(Int(duration.rounded(.up)), 1) + if seconds < 60 { return "\(seconds) seconds" } + let minutes = Int(ceil(Double(seconds) / 60)) + if minutes < 60 { return "\(minutes) minutes" } + let hours = Int(ceil(Double(minutes) / 60)) + return "\(hours) hours" + } +} \ No newline at end of file diff --git a/ios/WingDex/Preview Content/PreviewData.swift b/ios/WingDex/Preview Content/PreviewData.swift index 12aa9c90..b6ac567f 100644 --- a/ios/WingDex/Preview Content/PreviewData.swift +++ b/ios/WingDex/Preview Content/PreviewData.swift @@ -120,7 +120,7 @@ enum PreviewData { add("outing-001", "Song Sparrow (Melospiza melodia)") add("outing-001", "Black-capped Chickadee (Poecile atricapillus)") add("outing-001", "Blue Jay (Cyanocitta cristata)") - add("outing-001", "Northern Cardinal (Cardinalis cardinalis)") + add("outing-001", "Northern Cardinal (Cardinalis cardinalis)", count: 4) add("outing-001", "Steller's Jay (Cyanocitta stelleri)", count: 3) add("outing-001", "Dark-eyed Junco (Junco hyemalis)") @@ -131,7 +131,7 @@ enum PreviewData { add("outing-002", "Apapane (Himatione sanguinea)") add("outing-002", "Pacific Golden-Plover (Pluvialis fulva)") add("outing-002", "Warbling White-eye (Zosterops japonicus)") - add("outing-002", "Northern Cardinal (Cardinalis cardinalis)") + add("outing-002", "Northern Cardinal (Cardinalis cardinalis)", certainty: .possible) add("outing-002", "Eurasian Skylark (Alauda arvensis)") // Outing 3 - Jamaica Bay, NYC @@ -296,7 +296,7 @@ enum PreviewData { lastSeenDate: stats.lastDate, totalOutings: stats.outingIds.count, totalCount: stats.totalCount, - notes: "", + notes: species == sampleSpecies ? "Often seen near dense shrubs and woodland edges." : "", wikiTitle: wiki?.title, thumbnailUrl: wiki?.thumb ) diff --git a/ios/WingDex/Services/AuthService.swift b/ios/WingDex/Services/AuthService.swift index 66c8c683..77ba056d 100644 --- a/ios/WingDex/Services/AuthService.swift +++ b/ios/WingDex/Services/AuthService.swift @@ -20,6 +20,7 @@ final class AuthService: @unchecked Sendable { var userName: String? var userEmail: String? var userImage: String? + var signInMessage: String? private var sessionToken: String? /// Signed session token (includes HMAC suffix) for cookie-based auth. @@ -52,7 +53,7 @@ final class AuthService: @unchecked Sendable { } /// Validate the locally-cached session with the server. - /// Signs out on 401 (expired/revoked session) so the UI goes straight to + /// Signs out when Better Auth rejects the session so the UI goes straight to /// sign-in instead of flashing authenticated content. Network errors are /// ignored - the user may be offline with a valid cached session. func validateSession() async { @@ -61,18 +62,37 @@ final class AuthService: @unchecked Sendable { var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 5 + AuthenticatedRequest.instrument(&request) do { - let (_, response) = try await Self.bearerSession.data(for: request) - if let http = response as? HTTPURLResponse, http.statusCode == 401 { + let (data, response) = try await AuthenticatedRequest.data( + for: request, session: Self.bearerSession, + context: "Validate session", logger: log + ) + if let http = response as? HTTPURLResponse, + Self.sessionValidationRejects(statusCode: http.statusCode, data: data) { log.warning("Session rejected by server, signing out") - signOut() + invalidateSession(rejectedToken: token) } } catch { // Network error - don't sign out, user may be offline - log.info("Session validation skipped: \(error.localizedDescription)") + log.info("Session validation skipped because the request failed") } } + nonisolated static func sessionValidationRejects(statusCode: Int, data: Data) -> Bool { + if statusCode == 401 { return true } + guard (200...299).contains(statusCode), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let session = json["session"] as? [String: Any], + session["id"] is String, + let user = json["user"] as? [String: Any], + user["id"] is String + else { + return (200...299).contains(statusCode) + } + return false + } + // MARK: - OAuth Flows /// Sign in with GitHub via ASWebAuthenticationSession. @@ -128,15 +148,18 @@ final class AuthService: @unchecked Sendable { "idToken": ["token": identityToken], ] request.httpBody = try JSONSerialization.data(withJSONObject: body) + AuthenticatedRequest.instrument(&request) - let (data, response) = try await Self.bearerSession.data(for: request) + let (data, response) = try await AuthenticatedRequest.data( + for: request, session: Self.bearerSession, + context: "Apple sign-in", logger: log + ) guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - let body = String(data: data, encoding: .utf8) ?? "" - throw AuthError.oauthFailed("Apple sign-in failed (\(statusCode)): \(body)") + throw AuthError.oauthFailed("Apple sign-in failed (HTTP \(statusCode))") } try processTokenResponse(data: data, response: response) @@ -155,8 +178,12 @@ final class AuthService: @unchecked Sendable { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(Config.apiBaseURL.absoluteString, forHTTPHeaderField: "Origin") request.httpBody = Data("{}".utf8) + AuthenticatedRequest.instrument(&request) - let (data, response) = try await Self.bearerSession.data(for: request) + let (data, response) = try await AuthenticatedRequest.data( + for: request, session: Self.bearerSession, + context: "Anonymous sign-in", logger: log + ) guard let httpResponse = response as? HTTPURLResponse else { throw AuthError.oauthFailed("Invalid response") @@ -165,8 +192,7 @@ final class AuthService: @unchecked Sendable { log.info("Anonymous sign-in response: \(httpResponse.statusCode)") guard (200...299).contains(httpResponse.statusCode) else { - let body = String(data: data, encoding: .utf8) ?? "" - log.error("Anonymous sign-in failed: \(httpResponse.statusCode) \(body)") + log.error("Anonymous sign-in failed: HTTP \(httpResponse.statusCode)") throw AuthError.oauthFailed("Anonymous sign-in failed (\(httpResponse.statusCode))") } @@ -188,7 +214,7 @@ final class AuthService: @unchecked Sendable { log.debug("OAuth URL: \(signInURL)") let callbackURL = try await performWebAuth(url: signInURL) - log.debug("OAuth callback received: \(callbackURL)") + log.debug("OAuth callback received for provider: \(provider)") try processAuthCallback(url: callbackURL) log.info("OAuth sign-in succeeded for \(provider)") } @@ -196,6 +222,29 @@ final class AuthService: @unchecked Sendable { /// Sign out - clear all state. Session invalidation happens server-side via expiry. func signOut() { log.info("Signing out") + signInMessage = nil + clearSession() + } + + /// Clear a rejected session only if it is still the active session. + @discardableResult + func invalidateSession(rejectedToken: String) -> Bool { + guard sessionToken == rejectedToken else { return false } + signInMessage = "Your session expired. Please sign in again." + clearSession() + return true + } + + nonisolated static func isSameSession(currentToken: String?, initiatingToken: String) -> Bool { + currentToken == initiatingToken + } + + func consumeSignInMessage() -> String? { + defer { signInMessage = nil } + return signInMessage + } + + private func clearSession() { sessionToken = nil signedSessionToken = nil sessionExpiry = nil @@ -213,6 +262,13 @@ final class AuthService: @unchecked Sendable { /// Send name and image to Better Auth's update-user endpoint and persist on success. func updateProfile(name: String, image: String) async throws { let token = try validToken() + try await updateProfile(name: name, image: image, token: token) + } + + private func updateProfile(name: String, image: String, token: String) async throws { + guard Self.isSameSession(currentToken: sessionToken, initiatingToken: token) else { + throw AuthError.notAuthenticated + } let url = Config.apiBaseURL.appendingPathComponent("api/auth/update-user") var request = URLRequest(url: url) request.httpMethod = "POST" @@ -222,17 +278,21 @@ final class AuthService: @unchecked Sendable { let body: [String: String] = ["name": name.trimmingCharacters(in: .whitespacesAndNewlines), "image": image] request.httpBody = try JSONSerialization.data(withJSONObject: body) + AuthenticatedRequest.instrument(&request) - let (data, response) = try await Self.bearerSession.data(for: request) + let (_, response) = try await AuthenticatedRequest.data( + for: request, session: Self.bearerSession, + context: "Update profile", logger: log + ) guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - let detail = String(data: data, encoding: .utf8) ?? "" - throw AuthError.oauthFailed("Profile update failed (\(statusCode)): \(detail)") + if statusCode == 401 { + invalidateSession(rejectedToken: token) + } + throw AuthError.oauthFailed("Profile update failed (HTTP \(statusCode))") } - // Update in-memory state and persist to Keychain. - // The caller (ProfileEditor) also sets these optimistically - // before the network call, so this ensures they stay in sync. + guard Self.isSameSession(currentToken: sessionToken, initiatingToken: token) else { return } userName = name userImage = image persistSession() @@ -248,12 +308,18 @@ final class AuthService: @unchecked Sendable { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue(Config.apiBaseURL.absoluteString, forHTTPHeaderField: "Origin") request.httpBody = Data("{}".utf8) + AuthenticatedRequest.instrument(&request) - let (data, response) = try await Self.bearerSession.data(for: request) + let (_, response) = try await AuthenticatedRequest.data( + for: request, session: Self.bearerSession, + context: "Delete account", logger: log + ) guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - let detail = String(data: data, encoding: .utf8) ?? "" - throw AuthError.oauthFailed("Account deletion failed (\(statusCode)): \(detail)") + if statusCode == 401 { + invalidateSession(rejectedToken: token) + } + throw AuthError.oauthFailed("Account deletion failed (HTTP \(statusCode))") } signOut() @@ -300,8 +366,12 @@ final class AuthService: @unchecked Sendable { anonRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") anonRequest.setValue(Config.apiBaseURL.absoluteString, forHTTPHeaderField: "Origin") anonRequest.httpBody = Data("{}".utf8) + AuthenticatedRequest.instrument(&anonRequest) - let (anonData, anonResponse) = try await Self.bearerSession.data(for: anonRequest) + let (anonData, anonResponse) = try await AuthenticatedRequest.data( + for: anonRequest, session: Self.bearerSession, + context: "Create passkey account", logger: log + ) guard let anonHttp = anonResponse as? HTTPURLResponse, (200...299).contains(anonHttp.statusCode), let anonJson = try JSONSerialization.jsonObject(with: anonData) as? [String: Any], @@ -346,7 +416,10 @@ final class AuthService: @unchecked Sendable { contentType: "application/json" ) - let (_, finalizeResponse) = try await URLSession.shared.data(for: finalizeRequest) + let (_, finalizeResponse) = try await AuthenticatedRequest.data( + for: finalizeRequest, + context: "Finalize passkey account", logger: log + ) guard let finalizeHttp = finalizeResponse as? HTTPURLResponse, (200...299).contains(finalizeHttp.statusCode) else { @@ -376,11 +449,12 @@ final class AuthService: @unchecked Sendable { Task.detached { [weak self] in try? await Task.sleep(for: .seconds(2)) guard let self else { return } + guard await self.isCurrentSession(token: rawToken) else { return } await MainActor.run { self.clearAPICookies() } do { - try await self.updateProfile(name: birdName, image: avatarDataUrl) + try await self.updateProfile(name: birdName, image: avatarDataUrl, token: rawToken) } catch { - log.warning("Post-signup profile update failed: \(error.localizedDescription)") + log.warning("Post-signup profile update failed") } await MainActor.run { self.clearAPICookies() } } @@ -430,16 +504,19 @@ final class AuthService: @unchecked Sendable { let url = Config.apiBaseURL.appendingPathComponent("api/auth/get-session") var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + AuthenticatedRequest.instrument(&request) - let (data, response) = try await Self.bearerSession.data(for: request) + let (data, response) = try await AuthenticatedRequest.data( + for: request, session: Self.bearerSession, + context: "Fetch user info", logger: log + ) guard let httpResponse = response as? HTTPURLResponse else { log.warning("fetchUserInfo: non-HTTP response, skipping user-info enrichment") return } guard httpResponse.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) ?? "" - log.warning("fetchUserInfo: HTTP \(httpResponse.statusCode), body: \(body, privacy: .private)") + log.warning("fetchUserInfo: HTTP \(httpResponse.statusCode)") return } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -464,16 +541,21 @@ final class AuthService: @unchecked Sendable { /// Get a valid session token for API requests. /// Attach as `Authorization: Bearer `. func validToken() throws -> String { - guard let token = sessionToken, - let expiry = sessionExpiry, - expiry > Date.now - else { - signOut() + guard let token = sessionToken else { + clearSession() + throw AuthError.notAuthenticated + } + guard let expiry = sessionExpiry, expiry > Date.now else { + invalidateSession(rejectedToken: token) throw AuthError.notAuthenticated } return token } + private func isCurrentSession(token: String) -> Bool { + Self.isSameSession(currentToken: sessionToken, initiatingToken: token) + } + // MARK: - ASWebAuthenticationSession private func performWebAuth(url: URL) async throws -> URL { @@ -640,7 +722,8 @@ final class AuthService: @unchecked Sendable { guard let expiry = formatter.date(from: expiryString), expiry > Date.now else { - clearKeychain() + signInMessage = "Your session expired. Please sign in again." + clearSession() return } diff --git a/ios/WingDex/Services/AuthenticatedRequest.swift b/ios/WingDex/Services/AuthenticatedRequest.swift index bddc5a82..644750f8 100644 --- a/ios/WingDex/Services/AuthenticatedRequest.swift +++ b/ios/WingDex/Services/AuthenticatedRequest.swift @@ -1,5 +1,6 @@ import Foundation import os +import Security private let log = Logger(subsystem: Config.bundleID, category: "API") @@ -16,6 +17,33 @@ private let log = Logger(subsystem: Config.bundleID, category: "API") /// This helper encapsulates both patterns so callers don't need to know which to use. enum AuthenticatedRequest { + static func instrument(_ request: inout URLRequest) { + if request.value(forHTTPHeaderField: "Origin") == nil { + request.setValue(Config.apiBaseURL.absoluteString, forHTTPHeaderField: "Origin") + } + request.setValue(generateTraceparent(), forHTTPHeaderField: "traceparent") + } + + static func data( + for request: URLRequest, + session: URLSession = .shared, + context: String, + logger: Logger = log + ) async throws -> (Data, URLResponse) { + let start = Date() + do { + let (data, response) = try await session.data(for: request) + let durationMs = Int(Date().timeIntervalSince(start) * 1000) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger.debug("\(context): HTTP \(status) \(durationMs)ms \(data.count)B") + return (data, response) + } catch { + let durationMs = Int(Date().timeIntervalSince(start) * 1000) + logger.error("\(context): transport failure after \(durationMs)ms") + throw error + } + } + /// Build a request with Bearer token auth (for middleware-protected routes). static func withBearer( url: URL, @@ -32,6 +60,7 @@ enum AuthenticatedRequest { request.setValue(contentType, forHTTPHeaderField: "Content-Type") } request.httpBody = body + instrument(&request) return request } @@ -64,6 +93,7 @@ enum AuthenticatedRequest { cookieParts.append(extra) } request.setValue(cookieParts.joined(separator: "; "), forHTTPHeaderField: "Cookie") + instrument(&request) return request } @@ -79,12 +109,7 @@ enum AuthenticatedRequest { return result } - /// Validate an HTTP response, logging failures with private body redaction. - /// - /// Returns the HTTPURLResponse on success. Throws `PasskeyError.serverError` - /// with a user-friendly message (status code only) on failure. The raw response - /// body is logged at error level with `.private` privacy so it is redacted in - /// logs unless private data is explicitly enabled. + /// Validate an HTTP response without logging response bodies. @discardableResult static func validateHTTP( _ response: URLResponse, @@ -96,10 +121,28 @@ enum AuthenticatedRequest { (200...299).contains(http.statusCode) else { let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - let body = String(data: data.prefix(512), encoding: .utf8) ?? "" - logger.error("\(context): HTTP \(status), body: \(body, privacy: .private)") + if (400...499).contains(status) { + logger.warning("\(context): HTTP \(status)") + } else { + logger.error("\(context): HTTP \(status)") + } throw PasskeyError.serverError("\(context) (HTTP \(status))") } return http } + + private static func generateTraceparent() -> String { + var bytes = [UInt8](repeating: 0, count: 24) + let status = bytes.withUnsafeMutableBufferPointer { buffer in + SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!) + } + if status != errSecSuccess { + let fallback = (UUID().uuidString + UUID().uuidString) + .replacingOccurrences(of: "-", with: "") + .lowercased() + return "00-\(fallback.prefix(32))-\(fallback.dropFirst(32).prefix(16))-01" + } + let hex = bytes.map { String(format: "%02x", $0) }.joined() + return "00-\(hex.prefix(32))-\(hex.dropFirst(32).prefix(16))-01" + } } diff --git a/ios/WingDex/Services/DataService.swift b/ios/WingDex/Services/DataService.swift index a1f86e7f..ff5bfd50 100644 --- a/ios/WingDex/Services/DataService.swift +++ b/ios/WingDex/Services/DataService.swift @@ -1,6 +1,5 @@ import Foundation import os -import Security private let log = Logger(subsystem: Config.bundleID, category: "DataService") @@ -37,18 +36,56 @@ final class DataService: Sendable { try await delete("api/data/outings/\(id)") } - func updateOuting(id: String, fields: OutingUpdate) async throws { + func updateOuting(id: String, fields: OutingUpdate) async throws -> Outing { let data = try JSONEncoder().encode(fields) - try await patch("api/data/outings/\(id)", body: data) + let responseData = try await patch("api/data/outings/\(id)", body: data) + return try JSONDecoder().decode(Outing.self, from: responseData) } // MARK: - Observations - func rejectObservations(ids: [String]) async throws { - struct Update: Codable { let id: String; let certainty: String } - let updates = ids.map { Update(id: $0, certainty: "rejected") } - let data = try JSONEncoder().encode(updates) - try await patch("api/data/observations", body: data) + func rejectObservations(ids: [String]) async throws -> ObservationsResponse { + struct Patch: Codable { let certainty: String } + struct Update: Codable { let ids: [String]; let patch: Patch } + let data = try JSONEncoder().encode(Update(ids: ids, patch: Patch(certainty: "rejected"))) + let responseData = try await patch("api/data/observations", body: data) + return try JSONDecoder().decode(ObservationsResponse.self, from: responseData) + } + + // MARK: - Species + + struct SpeciesSearchResult: Codable, Identifiable, Sendable { + var id: String { ebirdCode ?? "\(common)|\(scientific)" } + let common: String + let scientific: String + let ebirdCode: String? + let wikiTitle: String? + } + + private struct SpeciesSearchResponse: Codable { + let results: [SpeciesSearchResult] + } + + func searchSpecies(query: String, limit: Int = 8) async throws -> [SpeciesSearchResult] { + var components = URLComponents( + url: Config.apiBaseURL.appendingPathComponent("api/species/search"), + resolvingAgainstBaseURL: false + ) + components?.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "limit", value: String(limit)), + ] + guard let url = components?.url else { + throw DataServiceError.invalidResponse + } + + var request = URLRequest(url: url) + let token = try await attachAuth(&request) + + let start = Date() + let (data, response) = try await Self.bearerSession.data(for: request) + try await validate(response, data: data, rejectedToken: token, path: "api/species/search", method: "GET", start: start, byteCount: data.count) + return try JSONDecoder().decode(SpeciesSearchResponse.self, from: data).results } // MARK: - Create Operations @@ -197,10 +234,11 @@ final class DataService: Sendable { request.httpMethod = "POST" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = body - try await attachAuth(&request) + let token = try await attachAuth(&request) + let start = Date() let (responseData, response) = try await Self.bearerSession.data(for: request) - try await validate(response, data: responseData, path: "api/import/ebird-csv", method: "POST", start: Date(), byteCount: responseData.count) + try await validate(response, data: responseData, rejectedToken: token, path: "api/import/ebird-csv", method: "POST", start: start, byteCount: responseData.count) let preview = try JSONDecoder().decode(ImportPreviewResponse.self, from: responseData) return preview.previews @@ -235,11 +273,11 @@ final class DataService: Sendable { private func getRaw(_ path: String) async throws -> Data { let url = Config.apiBaseURL.appendingPathComponent(path) var request = URLRequest(url: url) - try await attachAuth(&request) + let token = try await attachAuth(&request) let start = Date() let (data, response) = try await Self.bearerSession.data(for: request) - try await validate(response, data: data, path: path, method: "GET", start: start, byteCount: data.count) + try await validate(response, data: data, rejectedToken: token, path: path, method: "GET", start: start, byteCount: data.count) return data } @@ -250,11 +288,11 @@ final class DataService: Sendable { request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = data - try await attachAuth(&request) + let token = try await attachAuth(&request) let start = Date() let (responseData, response) = try await Self.bearerSession.data(for: request) - try await validate(response, data: responseData, path: path, method: "POST", start: start, byteCount: responseData.count) + try await validate(response, data: responseData, rejectedToken: token, path: path, method: "POST", start: start, byteCount: responseData.count) return responseData } @@ -265,11 +303,11 @@ final class DataService: Sendable { request.httpMethod = "PATCH" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = data - try await attachAuth(&request) + let token = try await attachAuth(&request) let start = Date() let (responseData, response) = try await Self.bearerSession.data(for: request) - try await validate(response, data: responseData, path: path, method: "PATCH", start: start, byteCount: responseData.count) + try await validate(response, data: responseData, rejectedToken: token, path: path, method: "PATCH", start: start, byteCount: responseData.count) return responseData } @@ -278,74 +316,74 @@ final class DataService: Sendable { let url = Config.apiBaseURL.appendingPathComponent(path) var request = URLRequest(url: url) request.httpMethod = "DELETE" - try await attachAuth(&request) + let token = try await attachAuth(&request) let start = Date() let (data, response) = try await Self.bearerSession.data(for: request) - try await validate(response, data: data, path: path, method: "DELETE", start: start, byteCount: data.count) + try await validate(response, data: data, rejectedToken: token, path: path, method: "DELETE", start: start, byteCount: data.count) return data } - private func attachAuth(_ request: inout URLRequest) async throws { + private func attachAuth(_ request: inout URLRequest) async throws -> String { let token = try await auth.validToken() request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue(Config.apiBaseURL.absoluteString, forHTTPHeaderField: "Origin") - request.setValue(Self.generateTraceparent(), forHTTPHeaderField: "traceparent") + AuthenticatedRequest.instrument(&request) let method = request.httpMethod ?? "?" let path = request.url?.path ?? "?" log.debug("Request: \(method) \(path)") + return token } - /// Generate a W3C traceparent header value for distributed tracing. - private static func generateTraceparent() -> String { - var bytes = [UInt8](repeating: 0, count: 24) - let status = bytes.withUnsafeMutableBufferPointer { buffer in - SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!) - } - if status != errSecSuccess { - // Fallback: derive bytes from two UUIDs (32 bytes > 24 needed) - let fallback = (UUID().uuidString + UUID().uuidString) - .replacingOccurrences(of: "-", with: "") - let fallbackHex = String(fallback.prefix(48)) - let traceId = String(fallbackHex.prefix(32)) - let spanId = String(fallbackHex.dropFirst(32).prefix(16)) - return "00-\(traceId.lowercased())-\(spanId.lowercased())-01" - } - let hex = bytes.map { String(format: "%02x", $0) }.joined() - let traceId = String(hex.prefix(32)) - let spanId = String(hex.dropFirst(32).prefix(16)) - return "00-\(traceId)-\(spanId)-01" - } - - private func validate(_ response: URLResponse, data: Data, path: String = "?", method: String = "?", start: Date? = nil, byteCount: Int? = nil) async throws { + private func validate(_ response: URLResponse, data: Data, rejectedToken: String, path: String = "?", method: String = "?", start: Date? = nil, byteCount: Int? = nil) async throws { guard let http = response as? HTTPURLResponse else { - throw DataServiceError.networkError("Invalid response") + throw DataServiceError.invalidResponse } let durationMs = start.map { Int(Date().timeIntervalSince($0) * 1000) } let durationFragment = durationMs.map { " \($0)ms" } ?? "" let bytesFragment = byteCount.map { " \($0)B" } ?? "" guard (200...299).contains(http.statusCode) else { - let body = String(data: data.prefix(1024), encoding: .utf8) ?? "" - log.error("\(method) \(path) -> HTTP \(http.statusCode)\(durationFragment)\(bytesFragment): \(body, privacy: .private)") + let status = http.statusCode + if (400...499).contains(status) { + log.warning("\(method) \(path) -> HTTP \(status)\(durationFragment)\(bytesFragment)") + } else { + log.error("\(method) \(path) -> HTTP \(status)\(durationFragment)\(bytesFragment)") + } // Server rejected the session - clear stale local auth state // so the UI shows the sign-in screen instead of a broken homepage. - if http.statusCode == 401 { - await auth.signOut() + if status == 401 { + await auth.invalidateSession(rejectedToken: rejectedToken) } - throw DataServiceError.httpError(http.statusCode) + throw DataServiceError.http( + status: status, + message: Self.safePublicMessage(status: status, data: data), + retryAfter: http.value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init) + ) } log.debug("\(method) \(path) -> HTTP \(http.statusCode)\(durationFragment)\(bytesFragment)") } + + private static func safePublicMessage(status: Int, data: Data) -> String? { + guard [400, 409, 422].contains(status), data.count <= 512, + let message = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !message.isEmpty, + !message.contains("<") + else { return nil } + return message + } } enum DataServiceError: LocalizedError { - case networkError(String) - case httpError(Int) + case network(URLError) + case invalidResponse + case http(status: Int, message: String?, retryAfter: TimeInterval?) var errorDescription: String? { switch self { - case .networkError(let msg): msg - case .httpError(let code): "HTTP \(code)" + case .network(let error): error.localizedDescription + case .invalidResponse: "Invalid response" + case .http(let status, let message, _): message ?? "HTTP \(status)" } } } diff --git a/ios/WingDex/Services/DataStore.swift b/ios/WingDex/Services/DataStore.swift index aea1419b..24180c8e 100644 --- a/ios/WingDex/Services/DataStore.swift +++ b/ios/WingDex/Services/DataStore.swift @@ -30,11 +30,12 @@ final class DataStore { // MARK: - State var isLoading = false - var error: String? + var error: AppError? // MARK: - Dependencies private let service: DataService + private var generation = 0 init(service: DataService) { self.service = service @@ -44,21 +45,37 @@ final class DataStore { /// Load all user data from the API. Called on app launch and pull-to-refresh. func loadAll() async { + let loadGeneration = generation log.info("Loading all data...") isLoading = true error = nil do { let response = try await service.fetchAllData() + guard generation == loadGeneration else { return } outings = response.outings photos = response.photos observations = response.observations dex = response.dex log.info("Loaded \(self.outings.count) outings, \(self.observations.count) observations, \(self.dex.count) dex entries") } catch { - self.error = error.localizedDescription - log.error("Failed to load data: \(error.localizedDescription)") + guard generation == loadGeneration else { return } + self.error = AppError.map(error) + log.error("Failed to load account data") } + if generation == loadGeneration { + isLoading = false + } + } + + /// Clear all account-owned state and invalidate in-flight bulk loads. + func reset() { + generation += 1 + outings = [] + photos = [] + observations = [] + dex = [] isLoading = false + error = nil } // MARK: - Derived Data @@ -121,36 +138,98 @@ final class DataStore { dex.first { $0.speciesName == speciesName } } + /// Search the server taxonomy for manual observation entry. + func searchSpecies(query: String, limit: Int = 8) async throws -> [DataService.SpeciesSearchResult] { + try await service.searchSpecies(query: query, limit: limit) + } + + /// Download one outing in eBird Record CSV format. + func exportOutingCSV(outingId: String) async throws -> Data { + try await service.exportOutingCSV(outingId: outingId) + } + // MARK: - Mutations /// Delete an outing and remove its observations locally, then sync with server. - func deleteOuting(id: String) async { + func deleteOuting(id: String) async throws { + let mutationGeneration = generation + let previousOutings = outings + let previousObservations = observations + let previousPhotos = photos outings.removeAll { $0.id == id } observations.removeAll { $0.outingId == id } photos.removeAll { $0.outingId == id } do { try await service.deleteOuting(id: id) } catch { - log.warning("deleteOuting failed, reconciling: \(error.localizedDescription)") + guard generation == mutationGeneration else { return } + outings = previousOutings + observations = previousObservations + photos = previousPhotos + log.warning("Outing deletion failed; reconciling account data") await loadAll() + throw error } } /// Mark observations as rejected (soft delete). - func rejectObservations(ids: [String]) async { + func rejectObservations(ids: [String]) async throws { + let mutationGeneration = generation + let previousObservations = observations + let previousDex = dex for i in observations.indices where ids.contains(observations[i].id) { observations[i].certainty = .rejected } do { - try await service.rejectObservations(ids: ids) + let response = try await service.rejectObservations(ids: ids) + guard generation == mutationGeneration else { return } + if let updated = response.observations { + let updatedById = Dictionary(uniqueKeysWithValues: updated.map { ($0.id, $0) }) + observations = observations.map { updatedById[$0.id] ?? $0 } + } + if let dexUpdates = response.dexUpdates { + dex = dexUpdates + } } catch { - log.warning("rejectObservations failed, reconciling: \(error.localizedDescription)") + guard generation == mutationGeneration else { return } + observations = previousObservations + dex = previousDex + log.warning("Observation rejection failed; reconciling account data") await loadAll() + throw error + } + } + + /// Add one observation and install the server's recomputed dex. + func addObservation(_ observation: BirdObservation) async throws { + let mutationGeneration = generation + let previousObservations = observations + let previousDex = dex + observations.append(observation) + do { + let response = try await service.createObservations([observation]) + guard generation == mutationGeneration else { return } + if let created = response.observations { + let createdById = Dictionary(uniqueKeysWithValues: created.map { ($0.id, $0) }) + observations = observations.map { createdById[$0.id] ?? $0 } + } + if let dexUpdates = response.dexUpdates { + dex = dexUpdates + } + } catch { + guard generation == mutationGeneration else { return } + observations = previousObservations + dex = previousDex + log.warning("Observation creation failed; reconciling account data") + await loadAll() + throw error } } /// Update outing fields locally and on the server. - func updateOuting(id: String, fields: OutingUpdate) async { + func updateOuting(id: String, fields: OutingUpdate) async throws { + let mutationGeneration = generation + let previousOutings = outings if let idx = outings.firstIndex(where: { $0.id == id }) { let old = outings[idx] outings[idx] = Outing( @@ -174,16 +253,25 @@ final class DataStore { ) } do { - try await service.updateOuting(id: id, fields: fields) + let updated = try await service.updateOuting(id: id, fields: fields) + guard generation == mutationGeneration else { return } + if let idx = outings.firstIndex(where: { $0.id == id }) { + outings[idx] = updated + } } catch { - log.warning("updateOuting failed, reconciling: \(error.localizedDescription)") + guard generation == mutationGeneration else { return } + outings = previousOutings + log.warning("Outing update failed; reconciling account data") await loadAll() + throw error } } /// Clear all user data. func clearAll() async throws { + let mutationGeneration = generation try await service.clearAllData() + guard generation == mutationGeneration else { return } outings = [] photos = [] observations = [] @@ -192,20 +280,24 @@ final class DataStore { /// Load demo data by importing the bundled eBird CSV. func loadDemoData() async throws { + let mutationGeneration = generation guard let csvURL = Bundle.main.url(forResource: "demo-ebird-import", withExtension: "csv"), let csvData = try? Data(contentsOf: csvURL) else { - throw DataServiceError.networkError("Demo CSV not found in bundle") + throw AppError.message("Demo data isn't available in this build.") } // Clear existing data first try await clearAll() + guard generation == mutationGeneration else { return } // Upload CSV for preview let previewIds = try await service.importEBirdCSV(csvData) + guard generation == mutationGeneration else { return } // Confirm all previews _ = try await service.confirmImport(previewIds: previewIds) + guard generation == mutationGeneration else { return } // Reload all data await loadAll() diff --git a/ios/WingDex/Services/PasskeyService.swift b/ios/WingDex/Services/PasskeyService.swift index 69f56f90..bf51f9b5 100644 --- a/ios/WingDex/Services/PasskeyService.swift +++ b/ios/WingDex/Services/PasskeyService.swift @@ -44,8 +44,12 @@ final class PasskeyService: NSObject, @unchecked Sendable { let optionsURL = Config.apiBaseURL.appendingPathComponent("api/auth/passkey/generate-authenticate-options") var optionsRequest = URLRequest(url: optionsURL) optionsRequest.setValue(Config.apiBaseURL.absoluteString, forHTTPHeaderField: "Origin") + AuthenticatedRequest.instrument(&optionsRequest) - let (optionsData, optionsResponse) = try await URLSession.shared.data(for: optionsRequest) + let (optionsData, optionsResponse) = try await AuthenticatedRequest.data( + for: optionsRequest, + context: "Passkey authentication options", logger: log + ) let httpResponse = try AuthenticatedRequest.validateHTTP( optionsResponse, data: optionsData, @@ -92,8 +96,12 @@ final class PasskeyService: NSObject, @unchecked Sendable { verifyRequest.setValue(cookies, forHTTPHeaderField: "Cookie") } verifyRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + AuthenticatedRequest.instrument(&verifyRequest) - let (verifyData, verifyResponse) = try await URLSession.shared.data(for: verifyRequest) + let (verifyData, verifyResponse) = try await AuthenticatedRequest.data( + for: verifyRequest, + context: "Passkey authentication verify", logger: log + ) let verifyHttp = try AuthenticatedRequest.validateHTTP( verifyResponse, data: verifyData, @@ -146,7 +154,10 @@ final class PasskeyService: NSObject, @unchecked Sendable { let optionsURL = components.url! let optionsRequest = AuthenticatedRequest.withCookieOnly(url: optionsURL, signedToken: signed) - let (optionsData, optionsResponse) = try await URLSession.shared.data(for: optionsRequest) + let (optionsData, optionsResponse) = try await AuthenticatedRequest.data( + for: optionsRequest, + context: "Passkey registration options", logger: log + ) let httpResponse = try AuthenticatedRequest.validateHTTP( optionsResponse, data: optionsData, @@ -200,7 +211,10 @@ final class PasskeyService: NSObject, @unchecked Sendable { ) log.debug("Verify request: challenge=\(challengeCookies != nil)") - let (verifyData, verifyResponse) = try await URLSession.shared.data(for: verifyRequest) + let (verifyData, verifyResponse) = try await AuthenticatedRequest.data( + for: verifyRequest, + context: "Passkey registration verify", logger: log + ) try AuthenticatedRequest.validateHTTP( verifyResponse, data: verifyData, @@ -215,7 +229,10 @@ final class PasskeyService: NSObject, @unchecked Sendable { let url = Config.apiBaseURL.appendingPathComponent("api/auth/passkey/list-user-passkeys") let request = AuthenticatedRequest.withCookieOnly(url: url, signedToken: signedToken) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await AuthenticatedRequest.data( + for: request, + context: "List passkeys", logger: log + ) try AuthenticatedRequest.validateHTTP( response, data: data, @@ -237,7 +254,10 @@ final class PasskeyService: NSObject, @unchecked Sendable { contentType: "application/json" ) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await AuthenticatedRequest.data( + for: request, + context: "Delete passkey", logger: log + ) try AuthenticatedRequest.validateHTTP( response, data: data, diff --git a/ios/WingDex/ViewModels/AddPhotosViewModel.swift b/ios/WingDex/ViewModels/AddPhotosViewModel.swift index da74853e..e7b996a6 100644 --- a/ios/WingDex/ViewModels/AddPhotosViewModel.swift +++ b/ios/WingDex/ViewModels/AddPhotosViewModel.swift @@ -116,7 +116,11 @@ final class AddPhotosViewModel { var processedCount = 0 var totalCount = 0 var extractionProgress: Double = 0 - var error: String? + var error: AppError? + private var errorRecovery: ErrorRecovery? + private var preparedObservations: [BirdObservation]? + + var canRetryError: Bool { errorRecovery != nil } // MARK: - Duplicate Detection @@ -131,6 +135,8 @@ final class AddPhotosViewModel { var savedOutingCount = 0 var savedObservationCount = 0 var newSpeciesCount = 0 + /// Display names of species newly added to the dex during this upload session. + var newSpeciesNames: [String] = [] // MARK: - Dependencies @@ -186,6 +192,7 @@ final class AddPhotosViewModel { // Reset accumulated stats for this upload session uploadSummary = nil + newSpeciesNames = [] var newPhotos: [ProcessedPhoto] = [] var duplicatePhotos: [ProcessedPhoto] = [] @@ -223,7 +230,7 @@ final class AddPhotosViewModel { newPhotos.append(photo) } } catch { - log.error("Failed to load photo: \(error.localizedDescription)") + log.error("Failed to load a selected photo") } processedCount += 1 extractionProgress = Double(processedCount) / Double(totalCount) * 100 @@ -253,7 +260,7 @@ final class AddPhotosViewModel { cameraPhotos = [] if newPhotos.isEmpty && duplicatePhotos.isEmpty { - error = "No photos to process" + error = .message("No photos to process.") currentStep = .selectPhotos isProcessing = false return @@ -332,15 +339,25 @@ final class AddPhotosViewModel { // Save photo metadata to server BEFORE AI identification starts. // The observation table has a FK to photo(id), so photos must exist first. Task { - await createPhotoMetadata(outingId: outingId) - await runSpeciesId(photoIndex: 0) + do { + try await createPhotoMetadata(outingId: outingId) + await runSpeciesId(photoIndex: 0) + } catch { + log.error("Failed to save photo metadata") + self.error = AppError.map(error, fallback: "Could not save photo details. Try again.") + errorRecovery = .photoMetadata + processingMessage = "Photo details could not be saved" + currentStep = .photoProcessing + } } } /// Persist photo metadata for the current cluster to the server. /// Must be called before creating observations (FK constraint on representativePhotoId). - private func createPhotoMetadata(outingId: String) async { - guard let service = dataService else { return } + private func createPhotoMetadata(outingId: String) async throws { + guard let service = dataService else { + throw AppError.message("Photo service isn't available.") + } let photos = clusterPhotos let formatter = ISO8601DateFormatter() let payloads = photos.map { photo in @@ -355,12 +372,8 @@ final class AddPhotosViewModel { fileName: photo.fileName ) } - do { - try await service.createPhotos(payloads) - log.info("Saved \(payloads.count) photo metadata records for outing \(outingId)") - } catch { - log.error("Failed to save photo metadata: \(error.localizedDescription)") - } + try await service.createPhotos(payloads) + log.info("Saved \(payloads.count) photo metadata records for outing \(outingId)") } // MARK: - Step 3: Species Identification (Two-Tier AI) @@ -377,6 +390,8 @@ final class AddPhotosViewModel { let photo = photos[photoIndex] currentPhotoIndex = photoIndex + error = nil + errorRecovery = nil photoProgress = 0 currentStep = .photoProcessing photoProgressTauMs = 1200 @@ -516,8 +531,13 @@ final class AddPhotosViewModel { currentStep = .perPhotoConfirm } } catch { - log.error("Species ID failed for photo \(photoIndex + 1): \(error.localizedDescription)") - self.error = error.localizedDescription + log.error("Species identification failed for photo index \(photoIndex + 1)") + self.error = AppError.map( + error, + fallback: "Could not identify this photo. Try again or skip it.", + rateLimit: Config.aiDailyRateLimit + ) + errorRecovery = .speciesIdentification(photoIndex: photoIndex, croppedImageData: croppedImageData) currentCandidates = [] rangeAdjusted = false currentStep = .perPhotoConfirm @@ -596,6 +616,7 @@ final class AddPhotosViewModel { isProcessing = true processingMessage = "Saving..." error = nil + errorRecovery = nil let confirmed = photoResults.filter { $0.status == .confirmed || $0.status == .possible } let existingSpecies = Set(store.dex.map(\.speciesName)) @@ -610,17 +631,18 @@ final class AddPhotosViewModel { } } - let observations = speciesMap.map { species, info in - BirdObservation( - id: "obs_\(UUID().uuidString)", - outingId: currentOutingId, - speciesName: species, - count: info.count, - certainty: info.status, - representativePhotoId: info.photoId, - notes: "" - ) - } + let observations = preparedObservations ?? speciesMap.map { species, info in + BirdObservation( + id: "obs_\(UUID().uuidString)", + outingId: currentOutingId, + speciesName: species, + count: info.count, + certainty: info.status, + representativePhotoId: info.photoId, + notes: "" + ) + } + preparedObservations = observations do { if !observations.isEmpty { @@ -636,6 +658,7 @@ final class AddPhotosViewModel { var clusterNewSpecies = 0 for obs in observations where !existingSpecies.contains(obs.speciesName) { clusterNewSpecies += 1 + newSpeciesNames.append(getDisplayName(obs.speciesName)) } newSpeciesCount += clusterNewSpecies savedOutingCount += 1 @@ -670,6 +693,7 @@ final class AddPhotosViewModel { // Move to next cluster or finish if currentClusterIndex < clusters.count - 1 { + preparedObservations = nil currentClusterIndex += 1 currentPhotoIndex = 0 photoResults = [] @@ -679,15 +703,41 @@ final class AddPhotosViewModel { currentStep = .outingReview } else { await store.loadAll() + preparedObservations = nil currentStep = .done } } catch { - self.error = error.localizedDescription - log.error("Save failed: \(error.localizedDescription)") + self.error = AppError.map(error, fallback: "Could not save this outing. Try again.") + errorRecovery = .saveCluster + log.error("Failed to save the current photo cluster") } isProcessing = false } + func retryCurrentError() { + let recovery = errorRecovery + error = nil + errorRecovery = nil + switch recovery { + case .photoMetadata: + Task { + do { + try await createPhotoMetadata(outingId: currentOutingId) + await runSpeciesId(photoIndex: currentPhotoIndex) + } catch { + self.error = AppError.map(error, fallback: "Could not save photo details. Try again.") + errorRecovery = .photoMetadata + } + } + case .speciesIdentification(let photoIndex, let croppedImageData): + Task { await runSpeciesId(photoIndex: photoIndex, croppedImageData: croppedImageData) } + case .saveCluster: + Task { await saveCurrentCluster() } + case nil: + break + } + } + // MARK: - Helpers /// Compress a UIImage to 640px max and encode as a data URL for the API. @@ -747,6 +797,12 @@ final class AddPhotosViewModel { } } +private enum ErrorRecovery { + case photoMetadata + case speciesIdentification(photoIndex: Int, croppedImageData: Data?) + case saveCluster +} + // MARK: - Supporting Types /// A photo after EXIF extraction and compression. diff --git a/ios/WingDex/Views/AddPhotosFlow/AddPhotosFlow.swift b/ios/WingDex/Views/AddPhotosFlow/AddPhotosFlow.swift index 312522d1..3d19ba69 100644 --- a/ios/WingDex/Views/AddPhotosFlow/AddPhotosFlow.swift +++ b/ios/WingDex/Views/AddPhotosFlow/AddPhotosFlow.swift @@ -11,6 +11,7 @@ struct AddPhotosFlow: View { @Environment(\.dismiss) private var dismiss @Bindable var viewModel: AddPhotosViewModel @State private var showCloseConfirm = false + @State private var celebration: LiferCelebration? /// Whether the current step needs a close confirmation (user has unsaved progress). private var needsCloseConfirmation: Bool { @@ -83,6 +84,32 @@ struct AddPhotosFlow: View { : "All \(dupCount) photos have already been imported.") } } + .celebration($celebration) + .alert("Could Not Continue", isPresented: addPhotosErrorBinding) { + if viewModel.canRetryError { + Button("Retry") { viewModel.retryCurrentError() } + Button("Close Upload", role: .destructive) { dismissWizard() } + } else { + Button("OK", role: .cancel) { viewModel.error = nil } + } + } message: { + Text(viewModel.error?.message ?? "Something went wrong. Try again.") + } + .onChange(of: viewModel.currentStep) { _, step in + if step == .done, !viewModel.newSpeciesNames.isEmpty { + celebration = LiferCelebration( + newSpeciesCount: viewModel.newSpeciesNames.count, + speciesNames: viewModel.newSpeciesNames + ) + } + } + } + + private var addPhotosErrorBinding: Binding { + Binding( + get: { viewModel.error != nil }, + set: { if !$0 { viewModel.error = nil } } + ) } /// Dismiss the wizard full-screen cover. The onDismiss handler in diff --git a/ios/WingDex/Views/AddPhotosFlow/OutingReviewView.swift b/ios/WingDex/Views/AddPhotosFlow/OutingReviewView.swift index bc1f610a..fb0d4c1d 100644 --- a/ios/WingDex/Views/AddPhotosFlow/OutingReviewView.swift +++ b/ios/WingDex/Views/AddPhotosFlow/OutingReviewView.swift @@ -39,6 +39,8 @@ struct OutingReviewView: View { /// Whether to add photos to an existing matching outing @State private var matchingOuting: Outing? @State private var useExistingOuting = false + @State private var isCreatingOuting = false + @State private var preparedOuting: Outing? /// Tracks whether the view has initiated geocoding for the current cluster. @State private var didInitialize = false @@ -119,7 +121,7 @@ struct OutingReviewView: View { } .accessibilityLabel("Continue") .buttonStyle(.borderedProminent) - .disabled(isLoadingLocation) + .disabled(isLoadingLocation || isCreatingOuting) } } .onAppear { initializeIfNeeded() } @@ -381,6 +383,8 @@ struct OutingReviewView: View { matchingOuting = nil useExistingOuting = false isLoadingLocation = false + isCreatingOuting = false + preparedOuting = nil } /// Initialize location lookup and matching outing detection. @@ -440,7 +444,7 @@ struct OutingReviewView: View { locationName = fallback suggestedLocation = fallback } catch { - log.error("Reverse geocoding failed: \(error.localizedDescription)") + log.error("Reverse geocoding failed") let fallback = viewModel.lastLocationName.isEmpty ? "\(roundedLat)deg, \(roundedLon)deg" : viewModel.lastLocationName @@ -612,7 +616,7 @@ struct OutingReviewView: View { do { mapItem = try await search.start().mapItems.first } catch { - log.debug("Place search failed: \(error.localizedDescription)") + log.debug("Place search failed") return } guard let mapItem else { return } @@ -653,6 +657,7 @@ struct OutingReviewView: View { /// Confirm the outing and proceed to species identification. private func handleConfirm() { + guard !isCreatingOuting else { return } if useExistingOuting, let existing = matchingOuting { // Merge into existing outing viewModel.outingConfirmed(outingId: existing.id, locationName: existing.locationName) @@ -660,12 +665,11 @@ struct OutingReviewView: View { } // Create new outing - let outingId = "outing_\(Int(Date().timeIntervalSince1970 * 1000))" let formatter = ISO8601DateFormatter() let finalLocationName = locationName.isEmpty ? "Unknown Location" : locationName - let outing = Outing( - id: outingId, + let outing = preparedOuting ?? Outing( + id: "outing_\(UUID().uuidString)", userId: "", startTime: formatter.string(from: effectiveStartTime), endTime: formatter.string(from: effectiveEndTime), @@ -678,15 +682,19 @@ struct OutingReviewView: View { notes: "", createdAt: formatter.string(from: Date()) ) + preparedOuting = outing + isCreatingOuting = true Task { + defer { isCreatingOuting = false } do { let service = DataService(auth: auth) let saved = try await service.createOuting(outing) + preparedOuting = nil viewModel.outingConfirmed(outingId: saved.id, locationName: finalLocationName) } catch { - log.error("Failed to create outing: \(error.localizedDescription)") - viewModel.error = error.localizedDescription + log.error("Failed to create outing") + viewModel.error = AppError.map(error, fallback: "Could not create this outing. Try again.") } } } @@ -829,7 +837,7 @@ final class PlaceSearchCompleter: NSObject { do { return try await search.start().mapItems.first } catch { - log.debug("Failed to resolve place search result: \(error.localizedDescription)") + log.debug("Failed to resolve place search result") return nil } } diff --git a/ios/WingDex/Views/AddPhotosFlow/PerPhotoConfirmView.swift b/ios/WingDex/Views/AddPhotosFlow/PerPhotoConfirmView.swift index a5897f03..db499723 100644 --- a/ios/WingDex/Views/AddPhotosFlow/PerPhotoConfirmView.swift +++ b/ios/WingDex/Views/AddPhotosFlow/PerPhotoConfirmView.swift @@ -616,7 +616,7 @@ struct PerPhotoConfirmView: View { } } catch is CancellationError { /* expected */ } catch { - log.debug("Commons gallery fetch failed: \(error.localizedDescription)") + log.debug("Commons gallery fetch failed") await MainActor.run { isLoadingWikiImage = false } } } diff --git a/ios/WingDex/Views/CelebrationOverlay.swift b/ios/WingDex/Views/CelebrationOverlay.swift new file mode 100644 index 00000000..85dc633b --- /dev/null +++ b/ios/WingDex/Views/CelebrationOverlay.swift @@ -0,0 +1,247 @@ +import SwiftUI + +/// A lifer celebration: one or more species just added to the user's WingDex. +/// +/// Mirrors the web app's confetti + lifer toast that fires when new species +/// are recorded (after an AddPhotos save or an eBird import). +struct LiferCelebration: Equatable, Identifiable { + let id = UUID() + var newSpeciesCount: Int + /// Display names of the newly added species. May be empty when only a count is known. + var speciesNames: [String] + + var bannerMessage: String { + Self.bannerMessage(newSpeciesCount: newSpeciesCount, speciesNames: speciesNames) + } + + /// Build the banner text. Pure function so it can be unit tested. + static func bannerMessage(newSpeciesCount: Int, speciesNames: [String]) -> String { + let names = speciesNames.filter { !$0.isEmpty } + guard !names.isEmpty else { + return "\(newSpeciesCount) new species added to your WingDex" + } + let preview = names.prefix(3).joined(separator: ", ") + let remainder = names.count - min(names.count, 3) + let suffix = remainder > 0 ? " +\(remainder) more" : "" + return "\(preview)\(suffix) added to your WingDex" + } +} + +extension View { + /// Present a lifer celebration (banner + confetti + success haptic) when the + /// bound value becomes non-nil. Auto-dismisses after a few seconds. Respects + /// Reduce Motion by skipping confetti and fading the banner in gently. + func celebration(_ celebration: Binding) -> some View { + modifier(CelebrationModifier(celebration: celebration)) + } +} + +// MARK: - Modifier + +private struct CelebrationModifier: ViewModifier { + @Binding var celebration: LiferCelebration? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @State private var confettiRunID: UUID? + + func body(content: Content) -> some View { + content + .overlay { + if let run = confettiRunID, !reduceMotion { + ConfettiView() + .id(run) + .allowsHitTesting(false) + .ignoresSafeArea() + } + } + .overlay(alignment: .top) { + if let celebration { + LiferBanner(message: celebration.bannerMessage) + .padding(.horizontal) + .transition( + reduceMotion + ? .opacity + : .move(edge: .top).combined(with: .opacity) + ) + } + } + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: celebration) + .sensoryFeedback(trigger: celebration) { _, newValue in + newValue != nil ? .success : nil + } + .onChange(of: celebration) { _, newValue in + guard let newValue else { return } + UIAccessibility.post(notification: .announcement, argument: newValue.bannerMessage) + + if !reduceMotion { + confettiRunID = newValue.id + Task { + try? await Task.sleep(for: .milliseconds(1400)) + if confettiRunID == newValue.id { + confettiRunID = nil + } + } + } + + Task { + try? await Task.sleep(for: .seconds(3)) + if celebration?.id == newValue.id { + celebration = nil + } + } + } + } +} + +// MARK: - Banner + +private struct LiferBanner: View { + let message: String + + var body: some View { + Label { + Text(message) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.foregroundText) + .multilineTextAlignment(.leading) + } icon: { + Image(systemName: "sparkles") + .foregroundStyle(Color.accentColor) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().stroke(Color.accentColor.opacity(0.3), lineWidth: 1)) + .shadow(color: .black.opacity(0.15), radius: 8, y: 4) + .padding(.top, 8) + .accessibilityElement(children: .combine) + .accessibilityLabel(message) + } +} + +// MARK: - Confetti + +/// A short, lightweight confetti burst drawn with Canvas + TimelineView. +/// +/// Particle positions use closed-form projectile motion so each frame can be +/// drawn purely from the elapsed time without mutable per-frame state. +private struct ConfettiView: View { + @State private var particles = ConfettiParticle.makeBurst(count: 80) + @State private var startDate = Date() + private let duration: TimeInterval = 1.4 + + var body: some View { + TimelineView(.animation) { timeline in + Canvas { context, size in + let elapsed = timeline.date.timeIntervalSince(startDate) + guard elapsed <= duration else { return } + + let fadeStart = duration * 0.6 + let globalFade = elapsed > fadeStart + ? 1 - (elapsed - fadeStart) / (duration - fadeStart) + : 1 + let frame = elapsed * 60 + let gravity = 0.25 + + for particle in particles { + let x = particle.startXFraction * size.width + particle.velocityX * frame + let y = particle.startYFraction * size.height + + particle.velocityY * frame + + 0.5 * gravity * frame * frame + let angle = Angle.degrees(particle.rotation + particle.rotationSpeed * frame) + + var layer = context + layer.opacity = globalFade + layer.translateBy(x: x, y: y) + layer.rotate(by: angle) + layer.fill(particle.path, with: .color(particle.color)) + } + } + } + } +} + +private struct ConfettiParticle { + enum Shape { case circle, rect, star } + + let startXFraction: Double + let startYFraction: Double + let velocityX: Double + let velocityY: Double + let size: Double + let color: Color + let rotation: Double + let rotationSpeed: Double + let shape: Shape + + /// Path centered on the origin, ready to be translated/rotated by the drawing context. + var path: Path { + switch shape { + case .circle: + return Path(ellipseIn: CGRect(x: -size / 2, y: -size / 2, width: size, height: size)) + case .rect: + return Path(CGRect(x: -size / 2, y: -size / 4, width: size, height: size / 2)) + case .star: + return Self.starPath(outerRadius: size / 2, innerRadius: size / 4) + } + } + + static func makeBurst(count: Int) -> [ConfettiParticle] { + let palette: [Color] = [.green, .blue, .orange, .red, .purple, .pink, .teal, .yellow] + let shapes: [Shape] = [.circle, .rect, .star] + return (0.. Path { + var path = Path() + let points = 5 + for i in 0..<(points * 2) { + let radius = i.isMultiple(of: 2) ? outerRadius : innerRadius + let angle = Double(i) * .pi / Double(points) - .pi / 2 + let point = CGPoint(x: cos(angle) * radius, y: sin(angle) * radius) + if i == 0 { + path.move(to: point) + } else { + path.addLine(to: point) + } + } + path.closeSubpath() + return path + } +} + +// MARK: - Preview + +#if DEBUG +#Preview { + struct Harness: View { + @State private var celebration: LiferCelebration? + var body: some View { + ZStack { + Color.pageBg.ignoresSafeArea() + Button("Celebrate") { + celebration = LiferCelebration( + newSpeciesCount: 3, + speciesNames: ["Northern Cardinal", "Blue Jay", "American Robin"] + ) + } + .buttonStyle(.borderedProminent) + } + .celebration($celebration) + } + } + return Harness() +} +#endif diff --git a/ios/WingDex/Views/DataManagementView.swift b/ios/WingDex/Views/DataManagementView.swift index b2facfed..d9e232e7 100644 --- a/ios/WingDex/Views/DataManagementView.swift +++ b/ios/WingDex/Views/DataManagementView.swift @@ -11,7 +11,7 @@ struct DataManagementView: View { @State private var showingDeleteAccountStep1 = false @State private var showingDeleteAccountStep2 = false @State private var isDeleting = false - @State private var deleteError: String? + @State private var deleteError: AppError? var body: some View { Form { @@ -63,7 +63,7 @@ struct DataManagementView: View { } if let deleteError { - Text(deleteError) + Text(deleteError.message) .font(.caption) .foregroundStyle(.red) } @@ -86,7 +86,7 @@ struct DataManagementView: View { do { try await store.clearAll() } catch { - deleteError = error.localizedDescription + deleteError = AppError.map(error, fallback: "Could not delete your data. Try again.") } } } @@ -102,7 +102,7 @@ struct DataManagementView: View { try await store.clearAll() try await auth.deleteAccount() } catch { - deleteError = "Failed to delete account: \(error.localizedDescription)" + deleteError = AppError.map(error, fallback: "Could not delete your account. Try again.") isDeleting = false } } diff --git a/ios/WingDex/Views/EBirdImportView.swift b/ios/WingDex/Views/EBirdImportView.swift index a9b624ae..ddab4eff 100644 --- a/ios/WingDex/Views/EBirdImportView.swift +++ b/ios/WingDex/Views/EBirdImportView.swift @@ -7,6 +7,8 @@ private let log = Logger(subsystem: Config.bundleID, category: "EBirdImport") /// eBird CSV import flow with timezone picker, help section, and conflict display. struct EBirdImportView: View { let auth: AuthService + /// Called after a successful import with the new-species count and their display names. + var onImported: ((Int, [String]) -> Void)? = nil @Environment(DataStore.self) private var store @Environment(\.dismiss) private var dismiss @@ -53,7 +55,7 @@ struct EBirdImportView: View { @State private var showFilePicker = false @State private var showHelp = false @State private var isImporting = false - @State private var importError: String? + @State private var importError: AppError? // Preview state @State private var previews: [DataService.ImportPreview] = [] @@ -130,7 +132,7 @@ struct EBirdImportView: View { if let importError { Section { - Text(importError) + Text(importError.message) .font(.caption) .foregroundStyle(.red) } @@ -204,13 +206,13 @@ struct EBirdImportView: View { switch result { case .failure(let error): - importError = error.localizedDescription + importError = AppError.map(error, fallback: "Could not open the selected file.") return case .success(let urls): guard let fileURL = urls.first else { return } guard fileURL.startAccessingSecurityScopedResource() else { - importError = "Cannot access the selected file" + importError = .message("Cannot access the selected file.") return } defer { fileURL.stopAccessingSecurityScopedResource() } @@ -232,7 +234,7 @@ struct EBirdImportView: View { showPreview = true isImporting = false } catch { - importError = "Failed to preview CSV: \(error.localizedDescription)" + importError = AppError.map(error, fallback: "Could not preview this CSV. Check the file and try again.") isImporting = false } } @@ -245,17 +247,24 @@ struct EBirdImportView: View { do { let service = DataService(auth: auth) + let priorSpecies = Set(store.dex.map(\.speciesName)) let result = try await service.confirmImport(previewIds: Array(selectedPreviewIds)) await store.loadAll() + let newSpeciesNames = store.dex + .map(\.speciesName) + .filter { !priorSpecies.contains($0) } + .map { getDisplayName($0) } + let message = "Imported eBird data across \(result.imported.outings) outing\(result.imported.outings == 1 ? "" : "s")" + (result.imported.newSpecies > 0 ? " (\(result.imported.newSpecies) new!)" : "") log.info("\(message)") + onImported?(result.imported.newSpecies, newSpeciesNames) dismiss() } catch { - importError = "Import failed: \(error.localizedDescription)" + importError = AppError.map(error, fallback: "Could not import this CSV. Try again.") isImporting = false } } diff --git a/ios/WingDex/Views/HomeView.swift b/ios/WingDex/Views/HomeView.swift index b194d1c6..08f60d43 100644 --- a/ios/WingDex/Views/HomeView.swift +++ b/ios/WingDex/Views/HomeView.swift @@ -40,6 +40,12 @@ struct HomeView: View { .refreshable { await store.loadAll() } + .alert("Could Not Refresh", isPresented: cachedLoadErrorBinding) { + Button("Retry") { Task { await store.loadAll() } } + Button("OK", role: .cancel) { store.error = nil } + } message: { + Text(store.error?.message ?? "Something went wrong. Try again.") + } .navigationDestination(for: DexEntry.self) { entry in SpeciesDetailView(speciesName: entry.speciesName) } @@ -55,11 +61,27 @@ struct HomeView: View { } } + private var cachedLoadErrorBinding: Binding { + Binding( + get: { store.error != nil && !store.dex.isEmpty }, + set: { if !$0 { store.error = nil } } + ) + } + @ViewBuilder private var rootContent: some View { if store.isLoading && store.dex.isEmpty { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = store.error, store.dex.isEmpty { + ContentUnavailableView { + Label("Could Not Load WingDex", systemImage: "wifi.exclamationmark") + } description: { + Text(error.message) + } actions: { + Button("Retry") { Task { await store.loadAll() } } + .buttonStyle(.borderedProminent) + } } else if store.dex.isEmpty { emptyState } else { @@ -275,4 +297,5 @@ struct HomeView: View { .environment(AuthService()) .environment(previewStore(empty: true)) } + #endif diff --git a/ios/WingDex/Views/NativeErrorPreviews.swift b/ios/WingDex/Views/NativeErrorPreviews.swift new file mode 100644 index 00000000..85e04e0a --- /dev/null +++ b/ios/WingDex/Views/NativeErrorPreviews.swift @@ -0,0 +1,105 @@ +#if DEBUG +import SwiftUI + +private struct PreviewScreen: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + NavigationStack { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.pageBg.ignoresSafeArea()) + .navigationTitle(title) + } + } +} + +private struct RefreshErrorPreview: View { + @State private var showError = true + + var body: some View { + PreviewScreen(title: "WingDex") { + List { + Section("Recent Species") { + Label("Northern Cardinal", systemImage: "bird.fill") + Label("American Robin", systemImage: "bird.fill") + } + } + .scrollContentBackground(.hidden) + } + .alert("Could Not Refresh", isPresented: $showError) { + Button("Retry") {} + Button("OK", role: .cancel) {} + } message: { + Text("You're offline. Check your connection and try again.") + } + } +} + +private struct AddPhotosErrorPreview: View { + @State private var showError = true + + var body: some View { + PreviewScreen(title: "Identify Photo") { + VStack(spacing: 16) { + ProgressView() + Text("Identifying bird...") + .foregroundStyle(.secondary) + } + } + .alert("Could Not Continue", isPresented: $showError) { + Button("Retry") {} + Button("Close Upload", role: .destructive) {} + } message: { + Text("AI identification limit reached (150 requests/day). Try again later.") + } + } +} + +private struct MutationErrorPreview: View { + @State private var showError = true + + var body: some View { + PreviewScreen(title: "Discovery Park") { + List { + Section("Outing") { + LabeledContent("Species", value: "12") + LabeledContent("Observations", value: "18") + } + } + .scrollContentBackground(.hidden) + } + .alert("Could Not Complete Action", isPresented: $showError) { + Button("OK", role: .cancel) {} + } message: { + Text("Could not save outing name. Try again.") + } + } +} + +#Preview("Initial Load Failure") { + PreviewScreen(title: "WingDex") { + ContentUnavailableView { + Label("Could Not Load WingDex", systemImage: "wifi.exclamationmark") + } description: { + Text("You're offline. Check your connection and try again.") + } actions: { + Button("Retry") {} + .buttonStyle(.borderedProminent) + } + } +} + +#Preview("Cached Refresh Failure") { + RefreshErrorPreview() +} + +#Preview("Add Photos Failure") { + AddPhotosErrorPreview() +} + +#Preview("Mutation Failure") { + MutationErrorPreview() +} +#endif \ No newline at end of file diff --git a/ios/WingDex/Views/OutingDetailView.swift b/ios/WingDex/Views/OutingDetailView.swift index cc6b2292..15857862 100644 --- a/ios/WingDex/Views/OutingDetailView.swift +++ b/ios/WingDex/Views/OutingDetailView.swift @@ -9,6 +9,18 @@ struct OutingDetailView: View { @State private var editingNotes = false @State private var notesText = "" @State private var contextMenuSpecies: String? + @State private var editingLocation = false + @State private var locationText = "" + @State private var showingAddSpecies = false + @State private var speciesQuery = "" + @State private var selectedSpecies: DataService.SpeciesSearchResult? + @State private var speciesResults: [DataService.SpeciesSearchResult] = [] + @State private var speciesSearchTask: Task? + @State private var isSearchingSpecies = false + @State private var isAddingSpecies = false + @State private var exportItem: ExportFileItem? + @State private var isExporting = false + @State private var operationError: String? private var outing: Outing? { store.outing(id: outingId) } private var confirmed: [BirdObservation] { store.confirmedObservations(outingId) } @@ -31,13 +43,28 @@ struct OutingDetailView: View { Button("Cancel", role: .cancel) {} Button("Delete Outing", role: .destructive) { Task { - await store.deleteOuting(id: outingId) - dismiss() + do { + try await store.deleteOuting(id: outingId) + dismiss() + } catch { + showError(error, fallback: "Could not delete outing. Try again.") + } } } } message: { Text("This will permanently delete this outing and all its observations.") } + .sheet(item: $exportItem) { item in + ActivityView(activityItems: [item.url]) + } + .alert("Could Not Complete Action", isPresented: operationErrorBinding) { + Button("OK", role: .cancel) { operationError = nil } + } message: { + Text(operationError ?? "Something went wrong. Try again.") + } + .onDisappear { + speciesSearchTask?.cancel() + } } @ViewBuilder @@ -65,11 +92,27 @@ struct OutingDetailView: View { // Actions Section { + Button { + Task { await exportOuting(outing) } + } label: { + if isExporting { + HStack { + ProgressView() + .controlSize(.mini) + Text("Exporting...") + } + } else { + Label("Export eBird CSV", systemImage: "square.and.arrow.up") + .foregroundStyle(Color.accentColor) + } + } + .disabled(confirmed.isEmpty || isExporting) + Button(role: .destructive) { showDeleteConfirm = true } label: { Label("Delete Outing", systemImage: "trash") - .font(.system(size: 14)) + .foregroundStyle(.red) } } } @@ -87,9 +130,51 @@ struct OutingDetailView: View { private func headerSection(_ outing: Outing) -> some View { VStack(alignment: .leading, spacing: 6) { - Text(outing.locationName.isEmpty ? "Outing" : outing.locationName) - .font(.system(.title2, design: .serif, weight: .bold)) - .foregroundStyle(Color.foregroundText) + if editingLocation { + TextField("Location name", text: $locationText) + .textFieldStyle(.roundedBorder) + + if !locationSuggestions.isEmpty { + ForEach(locationSuggestions, id: \.self) { suggestion in + Button { + locationText = suggestion + } label: { + Text(suggestion) + .font(.subheadline) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .tint(.primary) + } + } + + HStack { + Button("Cancel") { + editingLocation = false + locationText = outing.locationName + } + Spacer() + Button("Save") { + Task { await saveLocation(outing) } + } + .fontWeight(.semibold) + } + } else { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(outing.locationName.isEmpty ? "Outing" : outing.locationName) + .font(.system(.title2, design: .serif, weight: .bold)) + .foregroundStyle(Color.foregroundText) + + Button { + locationText = outing.locationName + editingLocation = true + } label: { + Image(systemName: "pencil") + } + .accessibilityLabel("Edit location name") + } + } HStack(spacing: 4) { Image(systemName: "calendar") @@ -197,6 +282,16 @@ struct OutingDetailView: View { .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } Section { + speciesSectionTitle( + title: "Species (\(Set(confirmed.map(\.speciesName)).count))", + showsAddAction: true + ) + .listRowSeparator(.hidden) + + if showingAddSpecies { + addSpeciesForm + } + if confirmed.isEmpty { Text("No confirmed observations") .font(.system(size: 14)) @@ -229,12 +324,20 @@ struct OutingDetailView: View { } .environment(store) } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + Task { + await removeSpecies( + displayName: getDisplayName(speciesName), + observationIds: obs.map(\.id) + ) + } + } label: { + Label("Remove", systemImage: "trash") + } + } } } - } header: { - Text("Species (\(Set(confirmed.map(\.speciesName)).count))") - .font(.system(size: 16, weight: .semibold, design: .serif)) - .foregroundStyle(Color.foregroundText) } } @@ -247,6 +350,9 @@ struct OutingDetailView: View { .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } Section { + speciesSectionTitle(title: "Possible (\(possible.count))") + .listRowSeparator(.hidden) + ForEach(grouped, id: \.key) { speciesName, obs in let totalCount = obs.reduce(0) { $0 + $1.count } let entry = store.dexEntry(for: speciesName) @@ -274,11 +380,43 @@ struct OutingDetailView: View { } .environment(store) } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + Task { + await removeSpecies( + displayName: getDisplayName(speciesName), + observationIds: obs.map(\.id) + ) + } + } label: { + Label("Remove", systemImage: "trash") + } + } + } + } + } + } + + private func speciesSectionTitle(title: String, showsAddAction: Bool = false) -> some View { + HStack { + Text(title) + .font(.system(size: 16, weight: .semibold, design: .serif)) + .foregroundStyle(Color.foregroundText) + Spacer() + if showsAddAction { + Button { + showingAddSpecies.toggle() + if !showingAddSpecies { + resetSpeciesForm() + } + } label: { + Label( + showingAddSpecies ? "Cancel" : "Add Species", + systemImage: showingAddSpecies ? "xmark" : "plus" + ) + .font(.subheadline) + .foregroundStyle(Color.accentColor) } - } header: { - Text("Possible (\(possible.count))") - .font(.system(size: 16, weight: .semibold, design: .serif)) - .foregroundStyle(Color.foregroundText) } } } @@ -302,8 +440,12 @@ struct OutingDetailView: View { Spacer() Button("Save") { Task { - await store.updateOuting(id: outingId, fields: OutingUpdate(notes: notesText)) - editingNotes = false + do { + try await store.updateOuting(id: outingId, fields: OutingUpdate(notes: notesText)) + editingNotes = false + } catch { + showError(error, fallback: "Could not save notes. Try again.") + } } } .fontWeight(.semibold) @@ -319,6 +461,201 @@ struct OutingDetailView: View { } } } + + // MARK: - Add Species + + private var addSpeciesForm: some View { + VStack(alignment: .leading, spacing: 10) { + TextField("Search species or enter a name", text: $speciesQuery) + .textInputAutocapitalization(.words) + .onChange(of: speciesQuery) { _, query in + if selectedSpecies?.common != query { + selectedSpecies = nil + } + scheduleSpeciesSearch(query) + } + + if isSearchingSpecies { + ProgressView() + .controlSize(.small) + } + + ForEach(speciesResults) { result in + Button { + speciesSearchTask?.cancel() + selectedSpecies = result + speciesQuery = result.common + speciesResults = [] + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(result.common) + .font(.subheadline) + .foregroundStyle(.primary) + Text(result.scientific) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .tint(.primary) + } + + Button { + Task { await addSpecies() } + } label: { + if isAddingSpecies { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Add Species") + .font(.system(size: 16, weight: .medium)) + .frame(maxWidth: .infinity, minHeight: 44) + } + } + .buttonStyle(.borderedProminent) + .disabled(speciesQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isAddingSpecies) + } + .padding(.vertical, 6) + } + + private var locationSuggestions: [String] { + let query = locationText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return [] } + + var seen = Set() + return store.outings + .map { $0.locationName.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted } + .filter { $0.localizedCaseInsensitiveContains(query) && $0.caseInsensitiveCompare(locationText) != .orderedSame } + .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + .prefix(8) + .map { $0 } + } + + private func saveLocation(_ outing: Outing) async { + let trimmed = locationText.trimmingCharacters(in: .whitespacesAndNewlines) + let currentName = outing.locationName.trimmingCharacters(in: .whitespacesAndNewlines) + let resetName = outing.defaultLocationName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let newName = trimmed.isEmpty + ? (resetName.isEmpty ? (currentName.isEmpty ? "Unknown Location" : currentName) : resetName) + : trimmed + let defaultName = outing.defaultLocationName ?? (currentName.isEmpty ? nil : currentName) + + do { + try await store.updateOuting( + id: outingId, + fields: OutingUpdate(locationName: newName, defaultLocationName: defaultName) + ) + editingLocation = false + } catch { + showError(error, fallback: "Could not save outing name. Try again.") + } + } + + private func scheduleSpeciesSearch(_ query: String) { + speciesSearchTask?.cancel() + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, selectedSpecies == nil else { + speciesResults = [] + isSearchingSpecies = false + return + } + + speciesSearchTask = Task { + do { + try await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled else { return } + isSearchingSpecies = true + let results = try await store.searchSpecies(query: trimmed) + guard !Task.isCancelled, + speciesQuery.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed + else { return } + speciesResults = results + } catch is CancellationError { + isSearchingSpecies = false + return + } catch { + speciesResults = [] + } + isSearchingSpecies = false + } + } + + private func addSpecies() async { + let trimmed = speciesQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let storedName: String + let displayName: String + if let selectedSpecies { + storedName = "\(selectedSpecies.common) (\(selectedSpecies.scientific))" + displayName = selectedSpecies.common + } else { + storedName = trimmed + displayName = getDisplayName(trimmed) + } + + isAddingSpecies = true + let observation = BirdObservation( + id: "obs_\(UUID().uuidString)", + outingId: outingId, + speciesName: storedName, + count: 1, + certainty: .confirmed, + notes: "Manually added" + ) + do { + try await store.addObservation(observation) + resetSpeciesForm() + showingAddSpecies = false + } catch { + showError(error, fallback: "Could not add \(displayName). Try again.") + } + isAddingSpecies = false + } + + private func resetSpeciesForm() { + speciesSearchTask?.cancel() + speciesQuery = "" + selectedSpecies = nil + speciesResults = [] + isSearchingSpecies = false + } + + private func removeSpecies(displayName: String, observationIds: [String]) async { + do { + try await store.rejectObservations(ids: observationIds) + } catch { + showError(error, fallback: "Could not remove \(displayName). Try again.") + } + } + + private func exportOuting(_ outing: Outing) async { + isExporting = true + do { + let csvData = try await store.exportOutingCSV(outingId: outing.id) + let date = String(outing.startTime.prefix(10)) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("wingdex-outing-\(date).csv") + try csvData.write(to: url) + exportItem = ExportFileItem(url: url) + } catch { + showError(error, fallback: "Could not export outing. Try again.") + } + isExporting = false + } + + private var operationErrorBinding: Binding { + Binding( + get: { operationError != nil }, + set: { if !$0 { operationError = nil } } + ) + } + + private func showError(_ error: Error, fallback: String) { + guard let appError = AppError.map(error, fallback: fallback) else { return } + operationError = appError.message + } } #if DEBUG diff --git a/ios/WingDex/Views/OutingsView.swift b/ios/WingDex/Views/OutingsView.swift index 419f10dc..81baf13c 100644 --- a/ios/WingDex/Views/OutingsView.swift +++ b/ios/WingDex/Views/OutingsView.swift @@ -107,6 +107,12 @@ struct OutingsView: View { .refreshable { await store.loadAll() } + .alert("Could Not Refresh", isPresented: cachedLoadErrorBinding) { + Button("Retry") { Task { await store.loadAll() } } + Button("OK", role: .cancel) { store.error = nil } + } message: { + Text(store.error?.message ?? "Something went wrong. Try again.") + } .searchable( text: $searchText, placement: .navigationBarDrawer(displayMode: .automatic), @@ -121,11 +127,27 @@ struct OutingsView: View { } } + private var cachedLoadErrorBinding: Binding { + Binding( + get: { store.error != nil && !store.outings.isEmpty }, + set: { if !$0 { store.error = nil } } + ) + } + // MARK: - Empty State @ViewBuilder private var rootContent: some View { - if store.outings.isEmpty { + if let error = store.error, store.outings.isEmpty { + ContentUnavailableView { + Label("Could Not Load Outings", systemImage: "wifi.exclamationmark") + } description: { + Text(error.message) + } actions: { + Button("Retry") { Task { await store.loadAll() } } + .buttonStyle(.borderedProminent) + } + } else if store.outings.isEmpty { VStack(spacing: 24) { Spacer() ZStack { diff --git a/ios/WingDex/Views/PasskeyManagementView.swift b/ios/WingDex/Views/PasskeyManagementView.swift index 4d3d3649..e09c1da2 100644 --- a/ios/WingDex/Views/PasskeyManagementView.swift +++ b/ios/WingDex/Views/PasskeyManagementView.swift @@ -6,7 +6,7 @@ struct PasskeyManagementView: View { @Environment(AuthService.self) private var auth @State private var passkeys: [PasskeyService.PasskeyInfo] = [] @State private var isLoading = true - @State private var errorMessage: String? + @State private var errorMessage: AppError? @State private var showAddSheet = false @State private var newPasskeyName = "" @State private var isAdding = false @@ -54,7 +54,7 @@ struct PasskeyManagementView: View { if let errorMessage { Section { - Text(errorMessage) + Text(errorMessage.message) .foregroundStyle(.red) .font(.caption) } @@ -105,7 +105,7 @@ struct PasskeyManagementView: View { do { passkeys = try await auth.listPasskeys() } catch { - errorMessage = error.localizedDescription + errorMessage = AppError.map(error, fallback: "Could not load passkeys. Try again.") } isLoading = false } @@ -119,10 +119,7 @@ struct PasskeyManagementView: View { try await auth.registerPasskey(name: finalName) await loadPasskeys() } catch { - // Don't show error for user cancellation - if (error as? ASAuthorizationError)?.code != .canceled { - errorMessage = error.localizedDescription - } + errorMessage = AppError.map(error, fallback: "Could not add this passkey. Try again.") } isAdding = false } @@ -152,7 +149,7 @@ struct PasskeyManagementView: View { try await auth.deletePasskey(id: id) passkeys.removeAll { $0.id == id } } catch { - errorMessage = error.localizedDescription + errorMessage = AppError.map(error, fallback: "Could not delete this passkey. Try again.") } } diff --git a/ios/WingDex/Views/SettingsView.swift b/ios/WingDex/Views/SettingsView.swift index 9204931e..7ac5073b 100644 --- a/ios/WingDex/Views/SettingsView.swift +++ b/ios/WingDex/Views/SettingsView.swift @@ -8,6 +8,7 @@ final class ProfileEditor { var name: String var image: String private let auth: AuthService + private let userId: String? private var pendingTask: Task? /// Original social provider avatar, captured once for restore-on-deselect. @@ -15,6 +16,7 @@ final class ProfileEditor { init(auth: AuthService) { self.auth = auth + self.userId = auth.userId self.name = auth.userName ?? "" self.image = auth.userImage ?? "" self.originalSocialImage = { @@ -23,7 +25,7 @@ final class ProfileEditor { }() } - var saveError: String? + var saveError: AppError? func save(name: String, image: String) { pendingTask?.cancel() @@ -34,13 +36,14 @@ final class ProfileEditor { do { try await auth.updateProfile(name: name, image: image) } catch { - saveError = error.localizedDescription + saveError = AppError.map(error, fallback: "Could not save your profile. Try again.") } } } /// Push final state back to auth so the rest of the app sees it. func syncToAuth() { + guard auth.userId == userId else { return } auth.userName = name auth.userImage = image } @@ -60,14 +63,15 @@ struct SettingsView: View { // Other state @State private var isLoadingDemo = false - @State private var demoError: String? + @State private var demoError: AppError? @State private var showingDemoConfirmation = false @State private var showingEBirdImport = false @State private var isExporting = false - @State private var exportError: String? + @State private var exportError: AppError? @State private var exportItem: ExportFileItem? @State private var isEditingName = false @State private var editedName = "" + @State private var celebration: LiferCelebration? private var profile: ProfileEditor { editor! } @@ -85,6 +89,7 @@ struct SettingsView: View { .onDisappear { editor?.syncToAuth() } + .celebration($celebration) } private var formContent: some View { @@ -94,7 +99,7 @@ struct SettingsView: View { if let saveError = profile.saveError { Section { - Text(saveError) + Text(saveError.message) .font(.caption) .foregroundStyle(.red) } @@ -144,7 +149,13 @@ struct SettingsView: View { } } .sheet(isPresented: $showingEBirdImport) { - EBirdImportView(auth: auth) + EBirdImportView(auth: auth) { newSpeciesCount, newSpeciesNames in + guard newSpeciesCount > 0 else { return } + celebration = LiferCelebration( + newSpeciesCount: newSpeciesCount, + speciesNames: newSpeciesNames + ) + } } .sheet(item: $exportItem) { item in ActivityView(activityItems: [item.url]) @@ -265,7 +276,7 @@ struct SettingsView: View { .disabled(store.dex.isEmpty || isExporting) if let exportError { - Text(exportError) + Text(exportError.message) .font(.caption) .foregroundStyle(.red) } @@ -347,7 +358,7 @@ struct SettingsView: View { .disabled(isLoadingDemo) if let demoError { - Text(demoError) + Text(demoError.message) .font(.caption) .foregroundStyle(.red) } @@ -364,7 +375,7 @@ struct SettingsView: View { do { try await store.loadDemoData() } catch { - demoError = error.localizedDescription + demoError = AppError.map(error, fallback: "Could not load demo data. Try again.") } isLoadingDemo = false } @@ -400,7 +411,7 @@ struct SettingsView: View { try csvData.write(to: tempURL) exportItem = ExportFileItem(url: tempURL) } catch { - exportError = error.localizedDescription + exportError = AppError.map(error, fallback: "Could not export sightings. Try again.") } isExporting = false } diff --git a/ios/WingDex/Views/SharedComponents.swift b/ios/WingDex/Views/SharedComponents.swift index fdb8b7ed..390cf98b 100644 --- a/ios/WingDex/Views/SharedComponents.swift +++ b/ios/WingDex/Views/SharedComponents.swift @@ -343,6 +343,7 @@ func openInMaps(outing: Outing, lat: Double, lon: Double) { struct OutingRow: View { let outing: Outing let store: DataStore + var observation: BirdObservation? var body: some View { let confirmed = store.confirmedObservations(outing.id) @@ -357,11 +358,26 @@ struct OutingRow: View { .foregroundStyle(Color.foregroundText) .lineLimit(1) - Text("\(DateFormatting.formatDate(outing.startTime, style: .medium)) \u{00B7} \(speciesNames.count) species") + if let observation { + HStack(spacing: 4) { + Text(DateFormatting.formatDate(outing.startTime, style: .medium)) + if observation.count > 1 { + Text("\u{00B7}") + Text("x\(observation.count)") + } + Text("\u{00B7}") + Text(observation.certainty.rawValue.capitalized) + .foregroundStyle(observation.certainty == .possible ? .orange : Color.mutedText) + } .font(.system(size: 13)) .foregroundStyle(Color.mutedText) + } else { + Text("\(DateFormatting.formatDate(outing.startTime, style: .medium)) \u{00B7} \(speciesNames.count) species") + .font(.system(size: 13)) + .foregroundStyle(Color.mutedText) + } - if !speciesNames.isEmpty { + if observation == nil, !speciesNames.isEmpty { Text( speciesNames.prefix(2).map { getDisplayName($0) }.joined(separator: ", ") + (speciesNames.count > 2 ? " +\(speciesNames.count - 2) more" : "") @@ -400,6 +416,7 @@ private struct MiniMapSnapshot: View { let latitude: Double let longitude: Double let size: CGFloat + @Environment(\.colorScheme) private var colorScheme @State private var image: UIImage? var body: some View { @@ -414,7 +431,7 @@ private struct MiniMapSnapshot: View { } } .frame(width: size, height: size) - .task { await snapshot() } + .task(id: colorScheme) { await snapshot() } } private func snapshot() async { @@ -426,6 +443,9 @@ private struct MiniMapSnapshot: View { ) // Use 2x for snapshot; actual screen scale not needed for thumbnails options.size = CGSize(width: size * 2, height: size * 2) + options.traitCollection = UITraitCollection( + userInterfaceStyle: colorScheme == .dark ? .dark : .light + ) options.pointOfInterestFilter = .excludingAll options.showsBuildings = false @@ -479,7 +499,18 @@ private struct MiniMapSnapshot: View { .background(Color.pageBg) } -#Preview("OutingRow") { +#Preview("OutingRow - Light") { + let store = previewStore() + List(PreviewData.outings.prefix(5)) { outing in + OutingRow(outing: outing, store: store) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color.pageBg) + .preferredColorScheme(.light) +} + +#Preview("OutingRow - Dark") { let store = previewStore() List(PreviewData.outings.prefix(5)) { outing in OutingRow(outing: outing, store: store) @@ -487,5 +518,6 @@ private struct MiniMapSnapshot: View { .listStyle(.plain) .scrollContentBackground(.hidden) .background(Color.pageBg) + .preferredColorScheme(.dark) } #endif diff --git a/ios/WingDex/Views/SignInView.swift b/ios/WingDex/Views/SignInView.swift index 461bc68c..4277b8a8 100644 --- a/ios/WingDex/Views/SignInView.swift +++ b/ios/WingDex/Views/SignInView.swift @@ -286,7 +286,10 @@ struct SignInView: View { } } .animation(.default, value: errorMessage) - .onAppear { startParallax() } + .onAppear { + errorMessage = auth.consumeSignInMessage() + startParallax() + } .onDisappear { stopParallax() } } } @@ -336,13 +339,9 @@ struct SignInView: View { Task { do { try await action() - } catch let error as ASAuthorizationError where error.code == .notHandled { - errorMessage = "Passkey not available for this domain. Check Associated Domains entitlement." - } catch let error as ASAuthorizationError where error.code == .canceled { - errorMessage = nil } catch { - errorMessage = error.localizedDescription - log.debug("Sign-in error: \(error.localizedDescription)") + errorMessage = AppError.map(error, fallback: "Authentication failed. Try again.")?.message + log.debug("Sign-in attempt failed") } isSigningIn = false } @@ -399,9 +398,17 @@ private struct SignInCollage: View { } #if DEBUG -#Preview { +#Preview("Sign In - Light") { + SignInView() + .environment(AuthService()) + .environment(previewStore(empty: true)) + .preferredColorScheme(.light) +} + +#Preview("Sign In - Dark") { SignInView() .environment(AuthService()) .environment(previewStore(empty: true)) + .preferredColorScheme(.dark) } #endif diff --git a/ios/WingDex/Views/SpeciesDetailView.swift b/ios/WingDex/Views/SpeciesDetailView.swift index f77727b7..dfae852c 100644 --- a/ios/WingDex/Views/SpeciesDetailView.swift +++ b/ios/WingDex/Views/SpeciesDetailView.swift @@ -37,7 +37,7 @@ struct SpeciesDetailView: View { Section { ForEach(sightings, id: \.observation.id) { item in NavigationLink(value: item.outing) { - OutingRow(outing: item.outing, store: store) + OutingRow(outing: item.outing, store: store, observation: item.observation) } .contextMenu { Button { @@ -64,6 +64,19 @@ struct SpeciesDetailView: View { .font(.system(size: 16, weight: .semibold, design: .serif)) .foregroundStyle(Color.foregroundText) } + + if let notes = entry?.notes.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { + Section { + Text(notes) + .font(.subheadline) + .italic() + .foregroundStyle(Color.mutedText) + } header: { + Text("Notes") + .font(.system(size: 16, weight: .semibold, design: .serif)) + .foregroundStyle(Color.foregroundText) + } + } } .listStyle(.plain) // WHY .scrollContentBackground(.hidden) + .background(): SwiftUI List has an @@ -181,7 +194,7 @@ struct SpeciesDetailView: View { if entry?.wikiTitle != nil { Text("Source: \(Text("Wikipedia").foregroundStyle(Color.accentColor)). Text and images available under \(Text("CC BY-SA 4.0").foregroundStyle(Color.accentColor)).") .font(.system(size: 11)) - .foregroundStyle(Color.mutedText.opacity(0.6)) + .foregroundStyle(Color.mutedText) } } } @@ -237,21 +250,23 @@ struct SpeciesDetailView: View { } #if DEBUG -#Preview("Species Detail - Cardinal") { +#Preview("Species Detail - Light") { PreviewTabs(.wingdex) { NavigationStack { SpeciesDetailView(speciesName: PreviewData.sampleSpecies) .environment(previewStore()) } } + .preferredColorScheme(.light) } -#Preview("Species Detail - Bald Eagle") { +#Preview("Species Detail - Dark") { PreviewTabs(.wingdex) { NavigationStack { - SpeciesDetailView(speciesName: "Bald Eagle (Haliaeetus leucocephalus)") + SpeciesDetailView(speciesName: PreviewData.sampleSpecies) .environment(previewStore()) } } + .preferredColorScheme(.dark) } #endif diff --git a/ios/WingDex/Views/WingDexView.swift b/ios/WingDex/Views/WingDexView.swift index d1c77375..94464c45 100644 --- a/ios/WingDex/Views/WingDexView.swift +++ b/ios/WingDex/Views/WingDexView.swift @@ -12,12 +12,13 @@ struct WingDexView: View { // MARK: - Sort Options enum DexSortField: String, CaseIterable { - case date, count, name + case date, count, name, family var label: String { switch self { case .date: "Date" case .count: "Count" case .name: "Name" + case .family: "Family" } } var icon: String { @@ -25,6 +26,7 @@ struct WingDexView: View { case .date: "calendar" case .count: "number" case .name: "textformat.abc" + case .family: "leaf" } } } @@ -49,6 +51,10 @@ struct WingDexView: View { .localizedCaseInsensitiveCompare(getDisplayName($1.speciesName)) return sortAscending ? cmp == .orderedAscending : cmp == .orderedDescending } + case .family: + sorted = store.dex.sorted { + taxonomicSpeciesPrecedes($0.speciesName, $1.speciesName, ascending: sortAscending) + } } if searchText.isEmpty { return sorted } @@ -65,55 +71,16 @@ struct WingDexView: View { .background(Color.pageBg.ignoresSafeArea()) .navigationTitle("WingDex") .toolbarTitleDisplayMode(.inlineLarge) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - // WHY HStack inside a single ToolbarItem instead of separate ToolbarItems: - // Multiple .topBarTrailing ToolbarItems have excessive system spacing - // between them (~16pt). An HStack lets us control the gap (5pt) to keep - // the sort button and avatar visually grouped like Apple Music. - HStack(spacing: 5) { - Menu { - Picker("Sort by", selection: $sortField) { - ForEach(DexSortField.allCases, id: \.self) { field in - Label(field.label, systemImage: field.icon) - .tag(field) - } - } - - Divider() - - Button { - sortAscending.toggle() - } label: { - Label( - sortAscending ? "Ascending" : "Descending", - systemImage: sortAscending ? "arrow.up" : "arrow.down" - ) - } - } label: { - Label("Sort", systemImage: "arrow.up.arrow.down") - } - // WHY explicit .glassEffect on sort: .sharedBackgroundVisibility(.hidden) - // below removes the default glass from ALL items in the ToolbarItem. - // We add it back on the sort button only so it gets the liquid glass - // pill while the avatar stays flat. - .glassEffect(.regular.interactive()) - - Button { showSettings() } label: { - AvatarView(imageURL: auth.userImage, name: auth.userName, size: 40) - } - } - .padding(.trailing, -12) - } - // WHY .sharedBackgroundVisibility(.hidden): the default behavior gives both - // the sort button and avatar a SHARED glass pill background. We want them - // independent - sort gets its own pill, avatar is flat - so we disable the - // shared background and add individual .glassEffect modifiers above. - .sharedBackgroundVisibility(.hidden) - } + .toolbar { toolbarContent } .refreshable { await store.loadAll() } + .alert("Could Not Refresh", isPresented: cachedLoadErrorBinding) { + Button("Retry") { Task { await store.loadAll() } } + Button("OK", role: .cancel) { store.error = nil } + } message: { + Text(store.error?.message ?? "Something went wrong. Try again.") + } .searchable( text: $searchText, placement: .navigationBarDrawer(displayMode: .automatic), @@ -125,14 +92,78 @@ struct WingDexView: View { .navigationDestination(item: $contextMenuSpecies) { entry in SpeciesDetailView(speciesName: entry.speciesName) } + .sensoryFeedback(.selection, trigger: sortField) + .sensoryFeedback(.selection, trigger: sortAscending) } } + private var cachedLoadErrorBinding: Binding { + Binding( + get: { store.error != nil && !store.dex.isEmpty }, + set: { if !$0 { store.error = nil } } + ) + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 5) { + Menu { + ForEach(DexSortField.allCases, id: \.self) { field in + Button { + selectSortField(field) + } label: { + Label(field.label, systemImage: field.icon) + } + } + + Divider() + + Button { + sortAscending.toggle() + } label: { + Label( + sortAscending ? "Ascending" : "Descending", + systemImage: sortAscending ? "arrow.up" : "arrow.down" + ) + } + } label: { + Label("Sort", systemImage: "arrow.up.arrow.down") + } + .glassEffect(.regular.interactive()) + + Button { showSettings() } label: { + AvatarView(imageURL: auth.userImage, name: auth.userName, size: 40) + } + } + .padding(.trailing, -12) + } + .sharedBackgroundVisibility(.hidden) + } + + private func selectSortField(_ field: DexSortField) { + if sortField == field { + sortAscending.toggle() + return + } + sortField = field + sortAscending = field == .name || field == .family + } + // MARK: - Empty State @ViewBuilder private var rootContent: some View { - if store.dex.isEmpty { + if let error = store.error, store.dex.isEmpty { + ContentUnavailableView { + Label("Could Not Load WingDex", systemImage: "wifi.exclamationmark") + } description: { + Text(error.message) + } actions: { + Button("Retry") { Task { await store.loadAll() } } + .buttonStyle(.borderedProminent) + } + } else if store.dex.isEmpty { VStack(spacing: 24) { Spacer() ZStack { diff --git a/ios/WingDexTests/AuthIntegrationTests.swift b/ios/WingDexTests/AuthIntegrationTests.swift index a810b0f2..5d9aff90 100644 --- a/ios/WingDexTests/AuthIntegrationTests.swift +++ b/ios/WingDexTests/AuthIntegrationTests.swift @@ -121,6 +121,118 @@ final class AuthIntegrationTests: XCTestCase { XCTAssertEqual(deleteHttp.statusCode, 200, "Delete outing should succeed") } + func testOutingDetailEditingRoundtrip() async throws { + let token = try await signInAnonymously() + let suffix = UUID().uuidString + let outingId = "outing-phase5-\(suffix)" + let observationId = "obs-phase5-\(suffix)" + + let createURL = baseURL.appendingPathComponent("api/data/outings") + var createRequest = URLRequest(url: createURL) + createRequest.httpMethod = "POST" + createRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + createRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + createRequest.httpBody = try JSONSerialization.data(withJSONObject: [ + "id": outingId, + "startTime": "2026-07-20T08:00:00.000Z", + "endTime": "2026-07-20T09:00:00.000Z", + "locationName": "Phase 5 Test Park", + "lat": 47.6205, + "lon": -122.3493, + "stateProvince": "US-WA", + "countryCode": "US", + "protocol": "Stationary", + "numberObservers": 1, + "allObsReported": true, + "createdAt": "2026-07-20T09:00:00.000Z", + ]) + + let (_, createResponse) = try await URLSession.shared.data(for: createRequest) + XCTAssertEqual(try XCTUnwrap(createResponse as? HTTPURLResponse).statusCode, 200) + + let updateURL = baseURL.appendingPathComponent("api/data/outings/\(outingId)") + var updateRequest = URLRequest(url: updateURL) + updateRequest.httpMethod = "PATCH" + updateRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + updateRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + updateRequest.httpBody = try JSONSerialization.data(withJSONObject: [ + "locationName": "Renamed Phase 5 Park", + "defaultLocationName": "Phase 5 Test Park", + ]) + let (updateData, updateResponse) = try await URLSession.shared.data(for: updateRequest) + XCTAssertEqual(try XCTUnwrap(updateResponse as? HTTPURLResponse).statusCode, 200) + let updatedOuting = try XCTUnwrap(try JSONSerialization.jsonObject(with: updateData) as? [String: Any]) + XCTAssertEqual(updatedOuting["locationName"] as? String, "Renamed Phase 5 Park") + XCTAssertEqual(updatedOuting["defaultLocationName"] as? String, "Phase 5 Test Park") + + var searchComponents = URLComponents( + url: baseURL.appendingPathComponent("api/species/search"), + resolvingAgainstBaseURL: false + ) + searchComponents?.queryItems = [ + URLQueryItem(name: "q", value: "American Robin"), + URLQueryItem(name: "limit", value: "8"), + ] + var searchRequest = URLRequest(url: try XCTUnwrap(searchComponents?.url)) + searchRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (searchData, searchResponse) = try await URLSession.shared.data(for: searchRequest) + XCTAssertEqual(try XCTUnwrap(searchResponse as? HTTPURLResponse).statusCode, 200) + let searchJSON = try XCTUnwrap(try JSONSerialization.jsonObject(with: searchData) as? [String: Any]) + let searchResults = try XCTUnwrap(searchJSON["results"] as? [[String: Any]]) + let robin = try XCTUnwrap(searchResults.first) + let common = try XCTUnwrap(robin["common"] as? String) + let scientific = try XCTUnwrap(robin["scientific"] as? String) + let speciesName = "\(common) (\(scientific))" + + let observationsURL = baseURL.appendingPathComponent("api/data/observations") + var addRequest = URLRequest(url: observationsURL) + addRequest.httpMethod = "POST" + addRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + addRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + addRequest.httpBody = try JSONSerialization.data(withJSONObject: [[ + "id": observationId, + "outingId": outingId, + "speciesName": speciesName, + "count": 1, + "certainty": "confirmed", + "notes": "Manually added", + ]]) + let (addData, addResponse) = try await URLSession.shared.data(for: addRequest) + XCTAssertEqual(try XCTUnwrap(addResponse as? HTTPURLResponse).statusCode, 200) + let addJSON = try XCTUnwrap(try JSONSerialization.jsonObject(with: addData) as? [String: Any]) + XCTAssertNotNil(addJSON["dexUpdates"] as? [[String: Any]]) + + let exportURL = baseURL.appendingPathComponent("api/export/outing/\(outingId)") + var exportRequest = URLRequest(url: exportURL) + exportRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (exportData, exportResponse) = try await URLSession.shared.data(for: exportRequest) + XCTAssertEqual(try XCTUnwrap(exportResponse as? HTTPURLResponse).statusCode, 200) + let csv = try XCTUnwrap(String(data: exportData, encoding: .utf8)) + XCTAssertTrue(csv.contains(common), "CSV should contain the confirmed species") + + var rejectRequest = URLRequest(url: observationsURL) + rejectRequest.httpMethod = "PATCH" + rejectRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + rejectRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + rejectRequest.httpBody = try JSONSerialization.data(withJSONObject: [ + "ids": [observationId], + "patch": ["certainty": "rejected"], + ]) + let (rejectData, rejectResponse) = try await URLSession.shared.data(for: rejectRequest) + XCTAssertEqual(try XCTUnwrap(rejectResponse as? HTTPURLResponse).statusCode, 200) + let rejectJSON = try XCTUnwrap(try JSONSerialization.jsonObject(with: rejectData) as? [String: Any]) + let rejected = try XCTUnwrap(rejectJSON["observations"] as? [[String: Any]]) + XCTAssertEqual(rejected.first?["certainty"] as? String, "rejected") + XCTAssertNotNil(rejectJSON["dexUpdates"] as? [[String: Any]]) + + let deleteURL = baseURL.appendingPathComponent("api/data/outings/\(outingId)") + var deleteRequest = URLRequest(url: deleteURL) + deleteRequest.httpMethod = "DELETE" + deleteRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (_, deleteResponse) = try await URLSession.shared.data(for: deleteRequest) + XCTAssertEqual(try XCTUnwrap(deleteResponse as? HTTPURLResponse).statusCode, 200) + } + // MARK: - Helpers /// Sign in anonymously and return the raw session token. diff --git a/ios/WingDexTests/AuthServiceTests.swift b/ios/WingDexTests/AuthServiceTests.swift index 0725e95b..a91dd82e 100644 --- a/ios/WingDexTests/AuthServiceTests.swift +++ b/ios/WingDexTests/AuthServiceTests.swift @@ -127,6 +127,132 @@ final class AuthCallbackParsingTests: XCTestCase { } } +// MARK: - Session Validation Tests + +final class SessionValidationTests: XCTestCase { + func testNullSuccessfulSessionIsRejected() { + XCTAssertTrue(AuthService.sessionValidationRejects(statusCode: 200, data: Data("null".utf8))) + } + + func testMalformedSuccessfulSessionIsRejected() { + XCTAssertTrue(AuthService.sessionValidationRejects(statusCode: 200, data: Data("{}".utf8))) + } + + func testSuccessfulSessionWithoutIdsIsRejected() { + let data = Data(#"{"session":{},"user":{}}"#.utf8) + XCTAssertTrue(AuthService.sessionValidationRejects(statusCode: 200, data: data)) + } + + func testValidSuccessfulSessionIsAccepted() { + let data = Data(#"{"session":{"id":"session-1"},"user":{"id":"user-1"}}"#.utf8) + XCTAssertFalse(AuthService.sessionValidationRejects(statusCode: 200, data: data)) + } + + func testUnauthorizedSessionIsRejected() { + XCTAssertTrue(AuthService.sessionValidationRejects(statusCode: 401, data: Data())) + } + + func testServerFailureDoesNotRejectCachedSession() { + XCTAssertFalse(AuthService.sessionValidationRejects(statusCode: 500, data: Data())) + } + + func testRejectedCurrentTokenInvalidatesSession() { + XCTAssertTrue(AuthService.isSameSession(currentToken: "token-a", initiatingToken: "token-a")) + } + + func testRejectedOldTokenDoesNotInvalidateReplacementSession() { + XCTAssertFalse(AuthService.isSameSession(currentToken: "token-b", initiatingToken: "token-a")) + } + + @MainActor + func testSignInMessageIsConsumedOnce() { + let auth = AuthService() + auth.signInMessage = "Your session expired. Please sign in again." + + XCTAssertEqual(auth.consumeSignInMessage(), "Your session expired. Please sign in again.") + XCTAssertNil(auth.consumeSignInMessage()) + } +} + +@MainActor +final class DataStoreSessionTests: XCTestCase { + func testResetClearsAccountOwnedState() { + let auth = AuthService() + let store = DataStore(service: DataService(auth: auth)) + store.outings = [Outing( + id: "outing-1", + userId: "user-1", + startTime: "2026-07-20T12:00:00Z", + endTime: "2026-07-20T13:00:00Z", + locationName: "Test Marsh", + notes: "", + createdAt: "2026-07-20T12:00:00Z" + )] + store.isLoading = true + store.error = .message("Previous account error") + + store.reset() + + XCTAssertTrue(store.outings.isEmpty) + XCTAssertTrue(store.photos.isEmpty) + XCTAssertTrue(store.observations.isEmpty) + XCTAssertTrue(store.dex.isEmpty) + XCTAssertFalse(store.isLoading) + XCTAssertNil(store.error) + } +} + +final class AppErrorTests: XCTestCase { + func testExistingAppErrorIsPreserved() { + let error = AppError.message("Specific recovery guidance") + XCTAssertEqual(AppError.map(error), error) + } + + func testOfflineMapping() { + XCTAssertEqual(AppError.map(URLError(.notConnectedToInternet)), .offline) + XCTAssertEqual(AppError.map(URLError(.networkConnectionLost)), .offline) + } + + func testTimeoutMapping() { + XCTAssertEqual(AppError.map(URLError(.timedOut)), .timedOut) + } + + func testCancellationIsSilent() { + XCTAssertNil(AppError.map(URLError(.cancelled))) + } + + func testRateLimitIncludesConfiguredLimitAndRetryAfter() { + let error = DataServiceError.http(status: 429, message: nil, retryAfter: 120) + let mapped = AppError.map(error, rateLimit: Config.aiDailyRateLimit) + XCTAssertEqual(mapped, .rateLimited(limit: Config.aiDailyRateLimit, retryAfter: 120)) + XCTAssertTrue(mapped?.message.contains("150 requests/day") == true) + XCTAssertTrue(mapped?.message.contains("2 minutes") == true) + } + + func testUnrelatedRateLimitUsesGenericCopy() { + let error = DataServiceError.http(status: 429, message: nil, retryAfter: 120) + XCTAssertEqual(AppError.map(error), .message("Too many requests. Try again later.")) + } + + func testAuthErrorsRespectPresentationContext() { + XCTAssertEqual(AppError.map(AuthError.notAuthenticated), .sessionExpired) + XCTAssertEqual( + AppError.map(AuthError.oauthFailed("unsafe detail"), fallback: "Could not save your profile. Try again."), + .message("Could not save your profile. Try again.") + ) + } + + func testSafeClientMessageIsPreserved() { + let error = DataServiceError.http(status: 409, message: "This import was already confirmed.", retryAfter: nil) + XCTAssertEqual(AppError.map(error), .message("This import was already confirmed.")) + } + + func testServerAndDecodingFailuresUseSafeCopy() { + XCTAssertEqual(AppError.map(DataServiceError.http(status: 500, message: nil, retryAfter: nil)), .server) + XCTAssertEqual(AppError.map(DataServiceError.invalidResponse), .invalidResponse) + } +} + // MARK: - Config Tests final class ConfigURLTests: XCTestCase { diff --git a/ios/WingDexTests/CelebrationTests.swift b/ios/WingDexTests/CelebrationTests.swift new file mode 100644 index 00000000..dc0628eb --- /dev/null +++ b/ios/WingDexTests/CelebrationTests.swift @@ -0,0 +1,33 @@ +@testable import WingDex +import XCTest + +final class CelebrationTests: XCTestCase { + func testBannerMessageWithNamesListsUpToThree() { + let message = LiferCelebration.bannerMessage( + newSpeciesCount: 2, + speciesNames: ["Northern Cardinal", "Blue Jay"] + ) + XCTAssertEqual(message, "Northern Cardinal, Blue Jay added to your WingDex") + } + + func testBannerMessageWithMoreThanThreeNamesAddsRemainder() { + let message = LiferCelebration.bannerMessage( + newSpeciesCount: 5, + speciesNames: ["Northern Cardinal", "Blue Jay", "American Robin", "House Finch", "Song Sparrow"] + ) + XCTAssertEqual(message, "Northern Cardinal, Blue Jay, American Robin +2 more added to your WingDex") + } + + func testBannerMessageWithoutNamesFallsBackToCount() { + let message = LiferCelebration.bannerMessage(newSpeciesCount: 3, speciesNames: []) + XCTAssertEqual(message, "3 new species added to your WingDex") + } + + func testBannerMessageIgnoresEmptyNames() { + let message = LiferCelebration.bannerMessage( + newSpeciesCount: 1, + speciesNames: ["", "Blue Jay"] + ) + XCTAssertEqual(message, "Blue Jay added to your WingDex") + } +} diff --git a/ios/WingDexTests/TaxonomyOrderTests.swift b/ios/WingDexTests/TaxonomyOrderTests.swift new file mode 100644 index 00000000..40cde50a --- /dev/null +++ b/ios/WingDexTests/TaxonomyOrderTests.swift @@ -0,0 +1,33 @@ +@testable import WingDex +import XCTest + +final class TaxonomyOrderTests: XCTestCase { + func testTaxonomyOrderUsesBundledSequence() { + XCTAssertLessThan(getTaxonomicOrder("Common Ostrich"), getTaxonomicOrder("Emu")) + } + + func testTaxonomyOrderStripsScientificNameAndIgnoresCase() { + XCTAssertEqual( + getTaxonomicOrder("common ostrich (Struthio camelus)"), + getTaxonomicOrder("Common Ostrich") + ) + } + + func testUnknownSpeciesSortAfterKnownSpecies() { + XCTAssertEqual(getTaxonomicOrder("Imaginary Bird"), Int.max) + XCTAssertLessThan(getTaxonomicOrder("Common Ostrich"), getTaxonomicOrder("Imaginary Bird")) + } + + func testUnknownSpeciesRemainLastInBothDirections() { + let species = ["Imaginary Bird", "Common Ostrich", "Emu", "Another Mystery"] + + XCTAssertEqual( + species.sorted { taxonomicSpeciesPrecedes($0, $1, ascending: true) }, + ["Common Ostrich", "Emu", "Another Mystery", "Imaginary Bird"] + ) + XCTAssertEqual( + species.sorted { taxonomicSpeciesPrecedes($0, $1, ascending: false) }, + ["Emu", "Common Ostrich", "Another Mystery", "Imaginary Bird"] + ) + } +} \ No newline at end of file diff --git a/scripts/ensure-app-on-5000.sh b/scripts/ensure-app-on-5000.sh index 339173ca..bdb770d4 100644 --- a/scripts/ensure-app-on-5000.sh +++ b/scripts/ensure-app-on-5000.sh @@ -47,7 +47,8 @@ release_lock() { } is_healthy() { - curl -fsS "${BASE_URL}" >/dev/null 2>&1 && curl -fsS "${BASE_URL}/api/health" >/dev/null 2>&1 + curl -fsS --connect-timeout 2 --max-time 5 "${BASE_URL}" >/dev/null 2>&1 \ + && curl -fsS --connect-timeout 2 --max-time 5 "${BASE_URL}/api/health" >/dev/null 2>&1 } start_dev() { diff --git a/src/App.tsx b/src/App.tsx index 19709a55..d3391488 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { BirdLogo } from '@/components/ui/bird-logo' import { useWingDexData } from '@/hooks/use-wingdex-data' import { getStableDevUserId } from '@/lib/dev-user' import { authClient } from '@/lib/auth-client' +import { fetchWithLocalAuthRetry } from '@/lib/local-auth-fetch' import { getEmojiAvatarColor } from '@/lib/fun-names' import { useAuthGate } from '@/hooks/use-auth-gate' import { loadDemoData } from '@/lib/demo-data' @@ -125,7 +126,7 @@ function App() { const fetchLinkedProviders = useCallback(async (): Promise => { try { - const response = await fetch('/api/auth/linked-providers', { + const response = await fetchWithLocalAuthRetry('/api/auth/linked-providers', { credentials: 'include', }) if (!response.ok) return [] diff --git a/src/ErrorFallback.tsx b/src/ErrorFallback.tsx index bf990d23..86097eda 100644 --- a/src/ErrorFallback.tsx +++ b/src/ErrorFallback.tsx @@ -1,3 +1,4 @@ +import { clientLog } from "./lib/client-log"; import { Alert, AlertTitle, AlertDescription } from "./components/ui/alert"; import { Button } from "./components/ui/button"; import type { FallbackProps } from "react-error-boundary"; @@ -5,10 +6,13 @@ import type { FallbackProps } from "react-error-boundary"; import { AlertTriangleIcon, RefreshCwIcon } from "lucide-react"; export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => { - // Log the error in dev mode but still show the boundary instead of rethrowing - // (rethrowing causes a cascade crash and blank screen) + // Record a safe dev-only boundary event without logging the error object. + // Rethrowing would cause a cascade crash and blank screen. if (import.meta.env.DEV) { - console.error('[ErrorFallback] Caught error:', error); + clientLog.error("web/errorBoundary/render", { + resultType: "Failed", + resultDescription: "The React error boundary caught an unhandled render error", + }); } return ( diff --git a/src/__tests__/client-log.test.ts b/src/__tests__/client-log.test.ts index ce50203f..57366522 100644 --- a/src/__tests__/client-log.test.ts +++ b/src/__tests__/client-log.test.ts @@ -80,11 +80,11 @@ describe('logClientFailure', () => { it('extracts status code from error message prefix', () => { const [entry] = captureLogs(() => logClientFailure('data/outings/write', new Error('400 Invalid JSON body'))) expect(entry).toMatchObject({ - level: 'Error', + level: 'Warning', operationName: 'data/outings/write', resultType: 'Failed', resultSignature: 400, - resultDescription: '400 Invalid JSON body', + resultDescription: 'Client request failed with HTTP 400', }) }) @@ -92,7 +92,7 @@ describe('logClientFailure', () => { const [entry] = captureLogs(() => logClientFailure('test/op', 'string error')) expect(entry).toMatchObject({ resultType: 'Failed', - resultDescription: 'string error', + resultDescription: 'Client request failed; inspect the operation and browser network trace', }) expect((entry as { resultSignature?: number }).resultSignature).toBeUndefined() }) @@ -100,7 +100,7 @@ describe('logClientFailure', () => { it('handles error without status prefix', () => { const [entry] = captureLogs(() => logClientFailure('test/op', new Error('Network error'))) expect(entry).toMatchObject({ - resultDescription: 'Network error', + resultDescription: 'Client request failed; inspect the operation and browser network trace', }) expect((entry as { resultSignature?: number }).resultSignature).toBeUndefined() }) diff --git a/src/__tests__/d1-chunk.test.ts b/src/__tests__/d1-chunk.test.ts new file mode 100644 index 00000000..03821e5d --- /dev/null +++ b/src/__tests__/d1-chunk.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { chunkIds, queryInChunks } from '../../functions/lib/d1-chunk' + +describe('chunkIds', () => { + it('returns empty array for empty input', () => { + expect(chunkIds([])).toEqual([]) + }) + + it('keeps small lists in a single chunk', () => { + expect(chunkIds([1, 2, 3], 90)).toEqual([[1, 2, 3]]) + }) + + it('splits lists larger than the chunk size', () => { + const ids = Array.from({ length: 200 }, (_, i) => i) + const chunks = chunkIds(ids, 90) + expect(chunks.map(c => c.length)).toEqual([90, 90, 20]) + expect(chunks.flat()).toEqual(ids) + }) + + it('rejects invalid chunk sizes', () => { + expect(() => chunkIds([1], 0)).toThrow() + }) +}) + +describe('queryInChunks', () => { + it('runs one query per chunk and concatenates rows within the D1 param limit', async () => { + const ids = Array.from({ length: 205 }, (_, i) => i) + const seenPlaceholderLengths: number[] = [] + const rows = await queryInChunks(ids, async (chunk, placeholders) => { + // placeholders binds one '?' per id; plus a leading userId stays <= 100 + seenPlaceholderLengths.push(placeholders.split(',').length) + return chunk.map(id => ({ id })) + }) + expect(rows.map(r => r.id)).toEqual(ids) + expect(seenPlaceholderLengths).toEqual([90, 90, 25]) + expect(Math.max(...seenPlaceholderLengths) + 1).toBeLessThanOrEqual(100) + }) + + it('makes no query for empty input', async () => { + let calls = 0 + const rows = await queryInChunks([], async () => { + calls++ + return [] + }) + expect(rows).toEqual([]) + expect(calls).toBe(0) + }) +}) diff --git a/src/__tests__/dex-query.test.ts b/src/__tests__/dex-query.test.ts index 740f0f64..11c077ec 100644 --- a/src/__tests__/dex-query.test.ts +++ b/src/__tests__/dex-query.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { computeDex, type DexQueryDB } from '../../functions/lib/dex-query' +import { computeDex, enrichDexEntries, type DexQueryDB } from '../../functions/lib/dex-query' type DexRow = { speciesName: string @@ -110,3 +110,30 @@ describe('computeDex', () => { expect(capturedSql).not.toContain("certainty = 'confirmed'") }) }) + +describe('enrichDexEntries', () => { + it('adds wiki metadata while preserving dex statistics and notes', () => { + const row: DexRow = { + speciesName: 'Northern Cardinal', + firstSeenDate: '2026-01-01T12:00:00Z', + lastSeenDate: '2026-01-02T12:00:00Z', + addedDate: null, + totalOutings: 2, + totalCount: 3, + bestPhotoId: null, + notes: 'Backyard visitor', + } + + const [entry] = enrichDexEntries([row]) + expect(entry).toMatchObject({ + speciesName: 'Northern Cardinal', + totalOutings: 2, + totalCount: 3, + notes: 'Backyard visitor', + }) + expect(entry.wikiTitle).toBeTruthy() + expect(entry.thumbnailUrl).toMatch(/^https:\/\//) + expect(entry.addedDate).toBeUndefined() + expect(entry.bestPhotoId).toBeUndefined() + }) +}) diff --git a/src/__tests__/observability-compliance.test.ts b/src/__tests__/observability-compliance.test.ts index 6ca69c8a..8e657cdd 100644 --- a/src/__tests__/observability-compliance.test.ts +++ b/src/__tests__/observability-compliance.test.ts @@ -31,11 +31,8 @@ function listTsFiles(dir: string): string[] { return results } -/** Handler files that are exempt from createRouteResponder (no DB calls, pure lookups). */ +/** Handler files that are exempt from createRouteResponder. */ const EXEMPT_HANDLERS = new Set([ - 'species/search.ts', - 'species/ebird-code.ts', - 'species/wiki-title.ts', 'auth/[[path]].ts', // Better Auth catch-all, not a route handler we instrument ]) diff --git a/src/__tests__/use-wingdex-data-race.test.tsx b/src/__tests__/use-wingdex-data-race.test.tsx index 7aa39528..166f8980 100644 --- a/src/__tests__/use-wingdex-data-race.test.tsx +++ b/src/__tests__/use-wingdex-data-race.test.tsx @@ -7,11 +7,40 @@ import { waitFor } from '@testing-library/dom' import { useEffect } from 'react' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { useWingDexData } from '@/hooks/use-wingdex-data' +import { rollbackItemsById, useWingDexData } from '@/hooks/use-wingdex-data' import type { Outing } from '@/lib/types' import type { Observation } from '@/lib/types' import type { WingDexDataStore } from '@/hooks/use-wingdex-data' +describe('rollbackItemsById', () => { + it('preserves unrelated concurrent items while removing a failed optimistic insert', () => { + const previous = [{ id: 'existing', value: 'before' }] + const current = [ + { id: 'existing', value: 'before' }, + { id: 'optimistic', value: 'pending' }, + { id: 'concurrent', value: 'new' }, + ] + + expect(rollbackItemsById(current, previous, new Set(['optimistic']))).toEqual([ + { id: 'existing', value: 'before' }, + { id: 'concurrent', value: 'new' }, + ]) + }) + + it('restores only the prior touched value and preserves unrelated changes', () => { + const previous = [{ id: 'edited', value: 'before' }] + const current = [ + { id: 'edited', value: 'optimistic' }, + { id: 'concurrent', value: 'new' }, + ] + + expect(rollbackItemsById(current, previous, new Set(['edited']))).toEqual([ + { id: 'edited', value: 'before' }, + { id: 'concurrent', value: 'new' }, + ]) + }) +}) + type Deferred = { promise: Promise resolve: (value: T) => void @@ -99,7 +128,7 @@ describe('useWingDexData addOuting race handling', () => { } act(() => { - latest!.addOuting(outing) + void latest!.addOuting(outing).catch(() => undefined) }) await waitFor(() => { diff --git a/src/components/flows/AddPhotosFlow.tsx b/src/components/flows/AddPhotosFlow.tsx index 992fa2c8..0757503e 100644 --- a/src/components/flows/AddPhotosFlow.tsx +++ b/src/components/flows/AddPhotosFlow.tsx @@ -1,3 +1,4 @@ +import { debug } from '@/lib/debug' import { useState, useRef, useEffect, useMemo } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { @@ -174,11 +175,11 @@ export default function AddPhotosFlow({ data, onClose, userId }: AddPhotosFlowPr setPhotoProgress(100) await wait(240) if (fastResult.multipleBirds) { - if (import.meta.env.DEV) console.log('Multiple birds detected by fast model, asking user to crop before escalation') + debug('bird-id', 'Multiple birds detected by fast model; requesting crop') toast.info('Multiple birds detected, crop to one') setCurrentCandidates(fastResult.candidates) } else { - if (import.meta.env.DEV) console.log('No species identified by fast model, asking user to crop before escalation') + debug('bird-id', 'No species identified by fast model; requesting crop') setCurrentCandidates([]) } setRangeAdjusted(false) @@ -213,7 +214,7 @@ export default function AddPhotosFlow({ data, onClose, userId }: AddPhotosFlowPr })() : fastResult - if (import.meta.env.DEV) console.log(`Found ${result.candidates.length} candidates`) + debug('bird-id', `Found ${result.candidates.length} candidates`) setPhotoProgress(100) await wait(240) @@ -228,11 +229,11 @@ export default function AddPhotosFlow({ data, onClose, userId }: AddPhotosFlowPr if (result.candidates.length === 0 && !imageUrl) { // No species found on full image, ask user to crop and retry - if (import.meta.env.DEV) console.log('No species identified, asking user to crop or skip') + debug('bird-id', 'No species identified; requesting crop or skip') setStep('photo-manual-crop') } else if (result.multipleBirds && !imageUrl) { // Multiple birds detected, let user crop to the one they want - if (import.meta.env.DEV) console.log('Multiple birds detected, asking user to crop') + debug('bird-id', 'Multiple birds detected; requesting crop') toast.info('Multiple birds detected, crop to one') setCurrentCandidates(result.candidates) setRangeAdjusted(result.rangeAdjusted === true) @@ -243,7 +244,7 @@ export default function AddPhotosFlow({ data, onClose, userId }: AddPhotosFlowPr setStep('photo-confirm') } } catch (error) { - if (import.meta.env.DEV) console.error('Species ID failed:', error) + debug('bird-id', 'Species identification failed') const msg = error instanceof Error ? error.message : 'Species identification failed' toast.error(msg) setCurrentCandidates([]) @@ -261,7 +262,9 @@ export default function AddPhotosFlow({ data, onClose, userId }: AddPhotosFlowPr setRangeAdjusted(false) void runSpeciesId(nextIdx) } else { - saveOuting(finalResults) + void saveOuting(finalResults).catch(() => { + toast.error('Could not save this outing. Try again.') + }) } } @@ -283,7 +286,7 @@ export default function AddPhotosFlow({ data, onClose, userId }: AddPhotosFlowPr const uploadStatsRef = useRef({ newSpecies: 0, outings: 0, totalSpecies: 0, totalCount: 0, locationNames: [] as string[] }) // ─── Save all observations and finish ──────────────────── - const saveOuting = (allResults: PhotoResult[]) => { + const saveOuting = async (allResults: PhotoResult[]) => { const confirmed = filterConfirmedResults(allResults) const existingSpecies = new Set(data.dex.map(entry => entry.speciesName)) @@ -321,8 +324,9 @@ export default function AddPhotosFlow({ data, onClose, userId }: AddPhotosFlowPr let liferMessage = '' if (observations.length > 0) { - data.addObservations(observations) + const persistObservations = data.addObservations(observations) const result = data.updateDex(currentOutingId, observations) + await persistObservations newSpeciesCount = result.newSpeciesCount const newSpeciesNames = observations .map(obs => obs.speciesName) @@ -416,13 +420,10 @@ export default function AddPhotosFlow({ data, onClose, userId }: AddPhotosFlowPr const file = files[i] try { const exif = await extractEXIF(file) - if (import.meta.env.DEV) console.log( - `${file.name}: EXIF = time:${exif.timestamp || 'none'}, GPS:${ - exif.gps - ? `${exif.gps.lat.toFixed(4)},${exif.gps.lon.toFixed(4)}` - : 'none' - }` - ) + debug('photo-import', 'Extracted photo metadata', { + hasTimestamp: !!exif.timestamp, + hasGps: !!exif.gps, + }) const thumbnail = await generateThumbnail(file) const hash = await computeFileHash(file) @@ -459,7 +460,7 @@ export default function AddPhotosFlow({ data, onClose, userId }: AddPhotosFlowPr newPhotos.push(photo) } } catch (error) { - if (import.meta.env.DEV) console.error(`Failed to process ${file.name}:`, error) + debug('photo-import', 'Failed to process a selected file') toast.error(`Failed to process ${file.name}`) } setProgress(((i + 1) / files.length) * 100) @@ -559,7 +560,7 @@ export default function AddPhotosFlow({ data, onClose, userId }: AddPhotosFlowPr fileHash: p.fileHash, fileName: p.fileName, })) - data.addPhotos(photosForStorage as Photo[]) + await data.addPhotos(photosForStorage as Photo[]) setPhotos(prev => prev.map(p => { const updated = updatedPhotos.find((up: any) => up.id === p.id) diff --git a/src/components/flows/OutingReview.tsx b/src/components/flows/OutingReview.tsx index 6eb69485..bdb1bda1 100644 --- a/src/components/flows/OutingReview.tsx +++ b/src/components/flows/OutingReview.tsx @@ -1,3 +1,4 @@ +import { debug } from '@/lib/debug' import { useState, useEffect, useCallback, useRef } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -8,6 +9,7 @@ import { Switch } from '@/components/ui/switch' import { findMatchingOuting } from '@/lib/clustering' import { dateToLocalISOWithOffset, toLocalISOWithOffset, formatStoredDate, formatStoredTimeWithTZ } from '@/lib/timezone' import type { WingDexDataStore } from '@/hooks/use-wingdex-data' +import type { Outing } from '@/lib/types' import { toast } from 'sonner' interface PhotoCluster { @@ -31,7 +33,7 @@ interface OutingReviewProps { locationName: string, lat?: number, lon?: number - ) => void + ) => Promise } function normalizeStateProvinceCode(raw?: string): string | undefined { @@ -81,6 +83,8 @@ export default function OutingReview({ const roundedLon = hasGps ? Number(cluster.centerLon!.toFixed(3)) : undefined const [locationName, setLocationName] = useState(defaultLocationName) const [isLoadingLocation, setIsLoadingLocation] = useState(false) + const [isConfirming, setIsConfirming] = useState(false) + const preparedOutingRef = useRef(null) const [suggestedLocation, setSuggestedLocation] = useState(defaultLocationName) const [inferredStateProvince, setInferredStateProvince] = useState(undefined) const [inferredCountryCode, setInferredCountryCode] = useState(undefined) @@ -128,7 +132,7 @@ export default function OutingReview({ // 2) natural feature at point (strait/bay/lake/cliff/etc.) // 3) neighborhood-level reverse geocode // 4) city-level reverse geocode - if (import.meta.env.DEV) console.log('Reverse geocoding via Nominatim...') + debug('geocoding', 'Starting reverse geocoding') const scoreResult = (result: any): number => { if (!result) return 0 @@ -275,18 +279,18 @@ export default function OutingReview({ if (!name) throw new Error('No location name returned') - if (import.meta.env.DEV) console.log('Location identified:', name) + debug('geocoding', 'Location identified') setSuggestedLocation(name) setLocationName(name) const region = extractRegionCodes(sourceResult) setInferredStateProvince(region.stateProvince) setInferredCountryCode(region.countryCode) } catch (error) { - if (import.meta.env.DEV) console.error('Reverse geocoding failed:', error) + debug('geocoding', 'Reverse geocoding failed') toast.warning('Could not look up location name, using coordinates instead') // Fall back to default location or coordinate string const fallback = defaultLocationName || `${lat.toFixed(4)}°, ${lon.toFixed(4)}°` - if (import.meta.env.DEV) console.log('Using fallback:', fallback) + debug('geocoding', 'Using location fallback') setSuggestedLocation(fallback) setLocationName(fallback) setInferredStateProvince(undefined) @@ -309,61 +313,72 @@ export default function OutingReview({ } }, [autoLookupGps, hasGps, matchingOuting, useExistingOuting, roundedLat, roundedLon, fetchLocationName]) - const doConfirm = (name: string) => { - if (useExistingOuting && matchingOuting) { - // Merge into existing outing, expand its time window if needed. - // cluster.startTime is a proper UTC instant (exifTime is offset-aware), - // so dateToLocalISOWithOffset correctly formats it in the outing's TZ. - const clusterStartISO = dateToLocalISOWithOffset( - cluster.startTime, matchingOuting.lat, matchingOuting.lon - ) - const clusterEndISO = dateToLocalISOWithOffset( - cluster.endTime, matchingOuting.lat, matchingOuting.lon - ) - const existingStartMs = new Date(matchingOuting.startTime).getTime() - const existingEndMs = new Date(matchingOuting.endTime).getTime() - const clusterStartMs = cluster.startTime.getTime() - const clusterEndMs = cluster.endTime.getTime() - - const needsTimeExpansion = clusterStartMs < existingStartMs || clusterEndMs > existingEndMs - const needsRegionFill = - (!matchingOuting.stateProvince && !!inferredStateProvince) || - (!matchingOuting.countryCode && !!inferredCountryCode) - - if (needsTimeExpansion || needsRegionFill) { - data.updateOuting(matchingOuting.id, { - startTime: needsTimeExpansion && clusterStartMs < existingStartMs ? clusterStartISO : matchingOuting.startTime, - endTime: needsTimeExpansion && clusterEndMs > existingEndMs ? clusterEndISO : matchingOuting.endTime, - stateProvince: matchingOuting.stateProvince || inferredStateProvince, - countryCode: matchingOuting.countryCode || inferredCountryCode, - }) + const doConfirm = async (name: string) => { + if (isConfirming) return + setIsConfirming(true) + try { + if (useExistingOuting && matchingOuting) { + // Merge into existing outing, expand its time window if needed. + // cluster.startTime is a proper UTC instant (exifTime is offset-aware), + // so dateToLocalISOWithOffset correctly formats it in the outing's TZ. + const clusterStartISO = dateToLocalISOWithOffset( + cluster.startTime, matchingOuting.lat, matchingOuting.lon + ) + const clusterEndISO = dateToLocalISOWithOffset( + cluster.endTime, matchingOuting.lat, matchingOuting.lon + ) + const existingStartMs = new Date(matchingOuting.startTime).getTime() + const existingEndMs = new Date(matchingOuting.endTime).getTime() + const clusterStartMs = cluster.startTime.getTime() + const clusterEndMs = cluster.endTime.getTime() + + const needsTimeExpansion = clusterStartMs < existingStartMs || clusterEndMs > existingEndMs + const needsRegionFill = + (!matchingOuting.stateProvince && !!inferredStateProvince) || + (!matchingOuting.countryCode && !!inferredCountryCode) + + if (needsTimeExpansion || needsRegionFill) { + data.updateOuting(matchingOuting.id, { + startTime: needsTimeExpansion && clusterStartMs < existingStartMs ? clusterStartISO : matchingOuting.startTime, + endTime: needsTimeExpansion && clusterEndMs > existingEndMs ? clusterEndISO : matchingOuting.endTime, + stateProvince: matchingOuting.stateProvince || inferredStateProvince, + countryCode: matchingOuting.countryCode || inferredCountryCode, + }) + } + + await onConfirm(matchingOuting.id, matchingOuting.locationName, matchingOuting.lat, matchingOuting.lon) + return } - onConfirm(matchingOuting.id, matchingOuting.locationName, matchingOuting.lat, matchingOuting.lon) - return - } + const outing = preparedOutingRef.current ?? { + id: `outing_${crypto.randomUUID()}`, + userId: userId.toString(), + startTime: dateToLocalISOWithOffset(effectiveStartTime, effectiveLat, effectiveLon), + endTime: dateToLocalISOWithOffset(effectiveEndTime, effectiveLat, effectiveLon), + locationName: name || 'Unknown Location', + defaultLocationName: name || 'Unknown Location', + lat: effectiveLat, + lon: effectiveLon, + stateProvince: inferredStateProvince, + countryCode: inferredCountryCode, + notes: '', + createdAt: new Date().toISOString() + } + preparedOutingRef.current = outing - const outingId = `outing_${Date.now()}` - const outing = { - id: outingId, - userId: userId.toString(), - startTime: dateToLocalISOWithOffset(effectiveStartTime, effectiveLat, effectiveLon), - endTime: dateToLocalISOWithOffset(effectiveEndTime, effectiveLat, effectiveLon), - locationName: name || 'Unknown Location', - defaultLocationName: name || 'Unknown Location', - lat: effectiveLat, - lon: effectiveLon, - stateProvince: inferredStateProvince, - countryCode: inferredCountryCode, - notes: '', - createdAt: new Date().toISOString() + await data.addOuting(outing) + await onConfirm(outing.id, name || 'Unknown Location', effectiveLat, effectiveLon) + preparedOutingRef.current = null + } finally { + setIsConfirming(false) } - - data.addOuting(outing) - onConfirm(outingId, name || 'Unknown Location', effectiveLat, effectiveLon) } - const handleConfirm = () => doConfirm(locationName) + const handleConfirm = () => { + void doConfirm(locationName).catch(() => { + toast.error('Could not continue saving this outing. Try again.') + }) + } const handleApplyDateTime = () => { const [year, month, day] = manualDate.split('-').map(Number) @@ -400,7 +415,7 @@ export default function OutingReview({ if (!controller.signal.aborted) setPlaceResults(results) } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return - if (import.meta.env.DEV) console.error('Place search failed:', error) + debug('geocoding', 'Place search failed') toast.error('Place search failed') } finally { if (!controller.signal.aborted) setIsSearchingPlace(false) @@ -644,10 +659,10 @@ export default function OutingReview({ diff --git a/src/components/pages/OutingsPage.tsx b/src/components/pages/OutingsPage.tsx index 0e8f315d..63cb41b2 100644 --- a/src/components/pages/OutingsPage.tsx +++ b/src/components/pages/OutingsPage.tsx @@ -380,6 +380,7 @@ function OutingDetail({ const [editingLocationName, setEditingLocationName] = useState(false) const [locationName, setLocationName] = useState(outing.locationName || '') const [addingSpecies, setAddingSpecies] = useState(false) + const [isAddingSpecies, setIsAddingSpecies] = useState(false) const [newSpeciesName, setNewSpeciesName] = useState('') const [selectedSpeciesEntry, setSelectedSpeciesEntry] = useState(null) const [deleteOutingOpen, setDeleteOutingOpen] = useState(false) @@ -443,8 +444,8 @@ function OutingDetail({ setPendingDeleteObservation({ ids: obsIds, speciesName }) } - const handleAddSpecies = () => { - if (!newSpeciesName.trim()) return + const handleAddSpecies = async () => { + if (!newSpeciesName.trim() || isAddingSpecies) return const normalizedName = selectedSpeciesEntry ? `${selectedSpeciesEntry.common} (${selectedSpeciesEntry.scientific})` @@ -458,12 +459,19 @@ function OutingDetail({ certainty: 'confirmed', notes: 'Manually added', } - data.addObservations([obs]) - data.updateDex(outing.id, [obs]) - setNewSpeciesName('') - setSelectedSpeciesEntry(null) - setAddingSpecies(false) - toast.success(`${getDisplayName(normalizedName)} added`) + setIsAddingSpecies(true) + try { + await data.addObservations([obs]) + data.updateDex(outing.id, [obs]) + setNewSpeciesName('') + setSelectedSpeciesEntry(null) + setAddingSpecies(false) + toast.success(`${getDisplayName(normalizedName)} added`) + } catch { + toast.error(`Could not add ${getDisplayName(normalizedName)}. Try again.`) + } finally { + setIsAddingSpecies(false) + } } const outingDate = new Date(outing.startTime) @@ -609,7 +617,7 @@ function OutingDetail({ />
- diff --git a/src/components/pages/SettingsPage.tsx b/src/components/pages/SettingsPage.tsx index b61842d7..babeae88 100644 --- a/src/components/pages/SettingsPage.tsx +++ b/src/components/pages/SettingsPage.tsx @@ -15,7 +15,7 @@ import { fetchWithLocalAuthRetry, isLocalRuntime } from '@/lib/local-auth-fetch' import { generateBirdName, emojiForBirdName, emojiAvatarDataUrl } from '@/lib/fun-names' import { buildPasskeyName, getDeviceLabelFromNavigator, isPasskeyCancellationLike, toStandardPasskeyLabel } from '@/lib/passkey-label' import { toast } from 'sonner' -import { logClientFailure } from '@/lib/client-log' +import { clientLog, logClientFailure } from '@/lib/client-log' import demoCsv from '@/assets/ebird-import.csv?raw' import type { WingDexDataStore } from '@/hooks/use-wingdex-data' @@ -176,7 +176,7 @@ export default function SettingsPage({ data, user, onSignIn, onSignedOut, onProf return formData } - const postPreview = () => fetch('/api/import/ebird-csv', { + const postPreview = () => fetchWithLocalAuthRetry('/api/import/ebird-csv', { method: 'POST', credentials: 'include', body: makePreviewFormData(), @@ -238,7 +238,10 @@ export default function SettingsPage({ data, user, onSignIn, onSignedOut, onProf } catch (error) { const detail = error instanceof Error ? error.message : 'Unknown error' toast.error(`Failed to import eBird data: ${detail}`) - if (import.meta.env.DEV) console.error(error) + clientLog.error('import/ebirdCsv/import', { + resultType: 'Failed', + resultDescription: 'The eBird CSV import flow failed during preview, confirmation, or refresh', + }) } if (importFileRef.current) { diff --git a/src/hooks/use-auth-gate.tsx b/src/hooks/use-auth-gate.tsx index c23279b3..498cd8f4 100644 --- a/src/hooks/use-auth-gate.tsx +++ b/src/hooks/use-auth-gate.tsx @@ -3,6 +3,7 @@ import { Key, GithubLogo, AppleLogo, GoogleChromeLogo } from '@phosphor-icons/re import { toast } from 'sonner' import { authClient } from '@/lib/auth-client' +import { fetchWithLocalAuthRetry } from '@/lib/local-auth-fetch' import { generateBirdName } from '@/lib/fun-names' import { buildPasskeyName, getDeviceLabelFromNavigator, isPasskeyCancellationLike } from '@/lib/passkey-label' import { @@ -131,7 +132,7 @@ function AuthGateModal({ ) // Finalize -- flip anonymous -> real user - const finalizeRes = await fetch('/api/auth/finalize-passkey', { + const finalizeRes = await fetchWithLocalAuthRetry('/api/auth/finalize-passkey', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, diff --git a/src/hooks/use-wingdex-data.ts b/src/hooks/use-wingdex-data.ts index e9731b6d..d1b098ab 100644 --- a/src/hooks/use-wingdex-data.ts +++ b/src/hooks/use-wingdex-data.ts @@ -15,6 +15,26 @@ type WingDexPayload = { type StorageMode = 'api' | 'local' +export function rollbackItemsById( + current: T[], + previous: T[], + touchedIds: Set, +): T[] { + const previousById = new Map(previous.map(item => [item.id, item])) + const result = current.flatMap(item => { + if (!touchedIds.has(item.id)) return [item] + const prior = previousById.get(item.id) + return prior ? [prior] : [] + }) + const resultIds = new Set(result.map(item => item.id)) + for (const item of previous) { + if (touchedIds.has(item.id) && !resultIds.has(item.id)) { + result.push(item) + } + } + return result +} + function rebuildDexFromState( allOutings: Outing[], allObservations: Observation[], @@ -211,12 +231,14 @@ export function useWingDexData(userId: string) { }) } - const addPhotos = (newPhotos: Photo[]) => { + const addPhotos = async (newPhotos: Photo[]): Promise => { if (newPhotos.length === 0) return + const previousPhotos = payloadRef.current.photos + const newIds = new Set(newPhotos.map(photo => photo.id)) const optimistic: WingDexPayload = { ...payloadRef.current, - photos: [...payloadRef.current.photos, ...newPhotos], + photos: [...payloadRef.current.photos.filter(photo => !newIds.has(photo.id)), ...newPhotos], } applyPayload(optimistic) @@ -236,40 +258,56 @@ export function useWingDexData(userId: string) { body: JSON.stringify(requestBody), }) - void postPhotos().catch(err => { - window.setTimeout(() => { - void postPhotos().catch(retryErr => logClientFailure('data/photos/write', retryErr, { count: requestBody.length, retried: true })) - }, 600) + try { + await postPhotos() + } catch (err) { logClientFailure('data/photos/write', err, { count: requestBody.length, willRetry: true }) - }) + await new Promise(resolve => window.setTimeout(resolve, 600)) + try { + await postPhotos() + } catch (retryErr) { + logClientFailure('data/photos/write', retryErr, { count: requestBody.length, retried: true }) + setPayload(current => ({ + ...current, + photos: rollbackItemsById(current.photos, previousPhotos, newIds), + })) + throw retryErr + } + } } } - const addOuting = (outing: Outing) => { + const addOuting = async (outing: Outing): Promise => { + const previousOutings = payloadRef.current.outings const optimistic: WingDexPayload = { ...payloadRef.current, - outings: [outing, ...payloadRef.current.outings], + outings: [outing, ...payloadRef.current.outings.filter(item => item.id !== outing.id)], } applyPayload(optimistic) if (storageMode === 'api') { - void apiJson('/api/data/outings', { - method: 'POST', - body: JSON.stringify(outing), - }) - .then(savedOuting => { - setPayload(current => { - const alreadyPresent = current.outings.some(item => item.id === savedOuting.id) - const next = { - ...current, - outings: alreadyPresent - ? current.outings.map(item => (item.id === savedOuting.id ? savedOuting : item)) - : [savedOuting, ...current.outings], - } - return next - }) + try { + const savedOuting = await apiJson('/api/data/outings', { + method: 'POST', + body: JSON.stringify(outing), + }) + setPayload(current => { + const alreadyPresent = current.outings.some(item => item.id === savedOuting.id) + return { + ...current, + outings: alreadyPresent + ? current.outings.map(item => (item.id === savedOuting.id ? savedOuting : item)) + : [savedOuting, ...current.outings], + } }) - .catch(err => logClientFailure('data/outings/write', err, { outingId: outing.id })) + } catch (err) { + logClientFailure('data/outings/write', err, { outingId: outing.id }) + setPayload(current => ({ + ...current, + outings: rollbackItemsById(current.outings, previousOutings, new Set([outing.id])), + })) + throw err + } } } @@ -324,36 +362,45 @@ export function useWingDexData(userId: string) { } } - const addObservations = (newObservations: Observation[]) => { + const addObservations = async (newObservations: Observation[]): Promise => { if (newObservations.length === 0) return + const previousObservations = payloadRef.current.observations + const newIds = new Set(newObservations.map(observation => observation.id)) const optimistic: WingDexPayload = { ...payloadRef.current, - observations: [...payloadRef.current.observations, ...newObservations], + observations: [ + ...payloadRef.current.observations.filter(observation => !newIds.has(observation.id)), + ...newObservations, + ], } applyPayload(optimistic) if (storageMode === 'api') { - void apiJson<{ observations: Observation[]; dexUpdates: DexEntry[] }>('/api/data/observations', { - method: 'POST', - body: JSON.stringify(newObservations), - }) - .then(response => { - setPayload(current => { - const byId = new Map(current.observations.map(observation => [observation.id, observation])) - for (const observation of response.observations || []) { - byId.set(observation.id, observation) - } - - const next = { - ...current, - observations: Array.from(byId.values()), - dex: response.dexUpdates || current.dex, - } - return next - }) + try { + const response = await apiJson<{ observations: Observation[]; dexUpdates: DexEntry[] }>('/api/data/observations', { + method: 'POST', + body: JSON.stringify(newObservations), }) - .catch(err => logClientFailure('data/observations/write', err, { count: newObservations.length })) + setPayload(current => { + const byId = new Map(current.observations.map(observation => [observation.id, observation])) + for (const observation of response.observations || []) { + byId.set(observation.id, observation) + } + return { + ...current, + observations: Array.from(byId.values()), + dex: response.dexUpdates || current.dex, + } + }) + } catch (err) { + logClientFailure('data/observations/write', err, { count: newObservations.length }) + setPayload(current => ({ + ...current, + observations: rollbackItemsById(current.observations, previousObservations, newIds), + })) + throw err + } } } diff --git a/src/lib/client-log.ts b/src/lib/client-log.ts index f60d1b53..d28c31d5 100644 --- a/src/lib/client-log.ts +++ b/src/lib/client-log.ts @@ -22,6 +22,7 @@ export interface ClientLogFields { } function emit(level: ClientLogLevel, operationName: string, fields?: ClientLogFields): void { + if (level === 'Debug' && !import.meta.env.DEV) return const entry: Record = { time: new Date().toISOString(), level, @@ -49,20 +50,23 @@ export const clientLog = { } /** - * Convenience: log a fetch/mutation failure with the error message and - * (when available) the parsed status code from the thrown Error. - * - * Server `apiJson()` throws Error(body || `${status} ${statusText}`). The body - * is the server-emitted resultDescription, so we surface it directly. + * Log a fetch/mutation failure using only safe operational metadata. + * The status prefix selects the severity, while thrown messages and response + * bodies are intentionally excluded from structured logs. */ export function logClientFailure(operationName: string, err: unknown, properties?: Record): void { const message = err instanceof Error ? err.message : String(err) const statusMatch = /^(\d{3})\s/.exec(message) const resultSignature = statusMatch ? Number(statusMatch[1]) : undefined - clientLog.error(operationName, { + const logger = resultSignature !== undefined && resultSignature >= 400 && resultSignature < 500 + ? clientLog.warn + : clientLog.error + logger(operationName, { resultType: 'Failed', resultSignature, - resultDescription: message, + resultDescription: resultSignature !== undefined + ? `Client request failed with HTTP ${resultSignature}` + : 'Client request failed; inspect the operation and browser network trace', properties, }) }