Skip to content

feat: Leave a Whisper + Place a Stone#26

Merged
momentmaker merged 49 commits intomainfrom
feat/whisper-cairn
Apr 7, 2026
Merged

feat: Leave a Whisper + Place a Stone#26
momentmaker merged 49 commits intomainfrom
feat/whisper-cairn

Conversation

@momentmaker
Copy link
Copy Markdown
Member

Summary

  • Leave a Whisper: ephemeral AI-narrated audio messages at GPS coordinates (max 7/walk, unlocks at 7 min). 18 whispers across 6 categories with expiry options (1 day / 7 days / 1 month). Auto-play on proximity with voice guide priority queue.
  • Place a Stone: permanent cairn markers that aggregate — multiple pilgrims adding stones to the same spot (50m merge radius) creates a growing cairn with 7 visual tiers. SHA-256 generative art per location. 1 per walk, unlocks at 12 min.
  • Shared infrastructure: proximity detection (700m/200m), geo-bounded caching (50km + ETag), offline sync queue, audio priority (voice guide > whisper > soundscape), centralized haptics, kanji expiry picker, map annotation rendering.

Worker changes (pilgrim-worker repo, not in this PR)

  • GET/POST /api/whispers — geo-bounded fetch + placement with rate limiting
  • GET/POST /api/cairns — atomic stone increment with centroid snapping + rate limiting
  • GET /api/cairns/world — global cairn map (LIMIT 5000, ETag, 1h cache)
  • Daily cron for whisper expiry cleanup
  • D1 migration for whispers + cairns tables
  • Input validation: lat/lon bounds, category allowlist, radius cap

Still needed before shipping

  • 18 whisper narration .m4a audio files in Support Files/Whispers/
  • 7 stone-on-stone .m4a audio files in Support Files/Stones/
  • Deploy D1 migration to production
  • Deploy worker with new endpoints
  • End-to-end test on device with real GPS

Test plan

  • Start walk, verify whisper/stone options locked in WalkOptionsSheet
  • Walk 7+ min, verify "Leave a Whisper" unlocks with "7 remaining"
  • Place whisper with each expiry option, verify map annotation appears
  • Walk 12+ min, verify "Place a Stone" unlocks
  • Place stone near existing cairn, verify count increments and haptic tier matches
  • Place stone at new location, verify new cairn created
  • Test proximity detection: walk near a whisper, verify notification + haptic + auto-play
  • Test proximity detection: walk near a cairn, verify notification + haptic
  • Test voice guide + whisper priority: whisper queues when guide is playing, plays after
  • Test offline placement: airplane mode → place whisper → restore → verify sync
  • Verify preview audio stops when closing WhisperPlacementSheet
  • Verify kanji expiry picker appears in both whisper sheet and walk share view

🤖 Generated with Claude Code

momentmaker and others added 30 commits March 30, 2026 19:46
Two "leave your mark" features for active walks. Whispers are ephemeral
AI-narrated audio messages left at GPS coordinates (1d/7d/1m expiry).
Cairns are permanent stone markers that aggregate — multiple pilgrims
adding stones to the same location creates a growing cairn with
tier-based visuals (faint → eternal at 108 stones).

Shared infrastructure:
- ProximityDetectionService: 5s throttled location checks against
  geo-bounded targets (700m whispers, 200m cairns)
- GeoCacheService: 50km geo-bounded fetch with ETag/If-None-Match
  validation, 10km re-fetch threshold, offline sync queue
- AudioPriorityQueue: voice guide > whisper > soundscape priority
  with ducking and deferred playback
- HapticManager: centralized haptic patterns with CHHapticEngine
  for deep stone feedback at higher cairn tiers
- CairnTier: 7 visual tiers driving map circle radius/opacity/glow
- KanjiExpiryPicker: shared expiry UI with kanji watermarks
- ProximityNotificationView: floating map overlay for nearby traces

Whisper feature:
- 18 pre-made whispers across 6 categories (courage, gratitude,
  stillness, wonder, compassion, presence)
- Category shown as border color, not label text
- Max 7 per walk, unlocks at 7 min walk time
- Auto-play on proximity (configurable in Sound settings)
- WhisperPlayer with preview in placement sheet

Cairn feature:
- 1 per walk, unlocks at 12 min walk time
- 50m merge radius with centroid snapping
- SHA-256 generative visual per location
- Stone-on-stone sounds with 7 tiers
- CairnDetailView shows count + generated art

Worker endpoints (pilgrim-worker, separate repo):
- GET/POST /api/whispers (geo-bounded, ETag, rate-limited)
- GET/POST /api/cairns (atomic increment, centroid, rate-limited)
- GET /api/cairns/world (LIMIT 5000, ETag, 1h cache)
- Daily cron for whisper expiry cleanup
- D1 migration for whispers + cairns tables

Still needed before shipping:
- 18 whisper narration .m4a audio files
- 7 stone-on-stone .m4a audio files
- D1 migration applied to production
- Worker deployed with new endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Already in .gitignore but was tracked before the rule was added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Re-fetch walk from CoreStore before building submission payload so
  transcriptions saved after the walk object was loaded are included
- Use walk.startDate in user's local timezone instead of UTC Date()
  for the submission date field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
108 — the number of mala beads. Minimum stays at 12 minutes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The freshRecordings fetch in PodcastSubmissionService.submit() was
crashing because it called DataManager.dataStack.fetchOne from an
async Task (background thread). CoreStore requires main-thread access.
Wrapped in MainActor.run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Opens podcast.pilgrimapp.org in an in-app Safari sheet.
Order: Podcast → Trail Note → Rate → Share (inward to outward).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7 categories mapped to guides: Presence (Breeze/Eric), Lightness
(Drift/Roger), Wonder (Dusk/Bill), Gratitude (Ember/Jessica),
Compassion (River/Bella), Courage (Sage/George), Stillness (Stone/Daniel).

Generated with ElevenLabs v3 [whispering] tag. Audio on R2 at
cdn.pilgrimapp.org/audio/whisper/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- WhisperPlayer: streams audio from cdn.pilgrimapp.org instead of
  local bundle. Uses AVPlayer for remote URL support.
- WhisperPlacementSheet: shows 7 categories (not individual whispers).
  User picks an energy, app randomly selects a whisper from that
  category. Preview plays a random whisper. No text shown — the
  message is a surprise.
- Expiry cleanup already deployed in worker cron.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Downloads all 21 whispers to Application Support/Whispers/ on first
use via downloadAll(). Plays from local cache when available, falls
back to remote URL if not yet downloaded. ~1.5MB total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Traces section (Leave a Whisper, Place a Stone) only shows when
device has network connectivity. Uses NWPathMonitor. Both features
require API access, so hiding offline is honest UX.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Critical:
- WhisperPlayer: download task is now cancellable with Task.isCancelled
- placeWhisper/placeStone: per-walk limits only consumed on API success,
  not on failure (prevents silent data loss on spotty connectivity)
- ProximityNotificationView: fix deprecated onChange to two-param form
- Remove dead offline payload code (makeOfflinePayload)

Important:
- WalkOptionsSheet: default isConnected=false to prevent flash
- StonePlayer: deactivate coordinator before re-activating on new play
- WhisperPlayer: remove unused Combine import
- WhisperPlayer: preview uses async download for remote URLs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Progressive revelation: pins appear within 2km, grow more vivid
as you approach. Whispers pulse at 700m. Cairns scale by tier.

- proximityAnnotations() builder: filters cached items to 2km radius,
  density-limits to 30 pins with 15m minimum separation
- Tracks encountered whisper/cairn IDs for future walk summary
- Newly placed whispers appear immediately on map via local cache
- No tap interaction — walk to things, don't tap them
- No animation timers — movement IS the animation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- PilgrimMapView: add onAnnotationTap callback with tap gesture
  recognizer. Hit-tests against whisper/cairn pins within 50m of
  tap location.
- Tap whisper: plays the whisper audio + haptic
- Tap cairn: shows CairnDetailView in a bottom sheet (stone visual,
  count, tier label)
- Polish: use raw coordinate math for density filter (no CLLocation
  allocations), static ISO8601DateFormatter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…w leak

Critical:
- WhisperPlayer.play(): downloads to cache first if not available,
  then plays from local file. Fixes AVAudioPlayer crash with HTTP URLs.
- WhisperPlayer: downloads all whispers on walk start (not just sheet
  open) so proximity auto-play works on first encounter.
- StonePlayer: graceful fallback if tier-specific audio not found.
- Preview task stored and cancelled in stop() to prevent stale playback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tiers 1-3: real stone recordings from Freesound (CC0)
Tiers 4-7: generated via ElevenLabs sound effects API
  - Tier 4: heavy stone thud
  - Tier 5: resonant rock impact with echo
  - Tier 6: ceremonial, reverberant
  - Tier 7: massive, rings like a bell, long decay

Note: files need to be added to Xcode project via IDE.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Stone placement: haptic fires immediately on tap, sound + state
  update only on API success
- ViewModel stop/cancel: clears all Combine subscriptions and resets
  proximity service to prevent background GPS/network after walk ends
- Reverted GeoCacheService @mainactor (would cascade too many changes;
  thread safety enforced in practice via Combine receive(on: .main))

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Auto-generated working file that captures session state before
conversation compaction. Not persistent — overwritten each time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…feedback

- "7 days" → "1 week" in KanjiExpiryPicker
- Stone subtitle shows "1 remaining" / "Placed" instead of generic "Available"
- Play whisper audio on successful placement as confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- placeStone() now appends a new CachedCairn when creating a brand-new
  cairn (not just updating existing ones), so the pin appears on the map
- Add light haptic feedback on successful waypoint drop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Without this, placing a whisper/cairn triggers your own proximity
notification ~5 seconds later when the proximity system picks up the
new cache entry. Now pre-marks placed items as already-notified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…, shareDate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
momentmaker and others added 19 commits April 2, 2026 10:24
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add shareVersion counter to WalkSharingButtons so the view reliably
  re-evaluates after the share sheet dismisses (onDismiss bumps counter,
  .id() modifier forces re-render)
- Capture Date() once in cacheShare to avoid shareDate/expiry skew

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Whisper, cairn, and waypoint pins don't appear on the active walk map
despite correct data flow. Logging at 3 points: proximityAnnotations()
output, applyAnnotations() input, and circleManager.annotations set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
References were accidentally removed in 0e84beb when cleaning up
duplicate Finder copies — the cleanup deleted all stone-tier refs,
not just the duplicates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Xcode kept adding references to Finder's "stone-tier-X 2.m4a" copies
instead of the originals. Edited pbxproj directly to reference the
correct filenames.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Console.app can read these after disconnected testing. Filter by
category "MapDebug" in Console.app with iPhone connected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
42 (the answer), 108 (mala beads / eternal tier), 21 (whisper count).
Whisper auto-play requires walking practically on top of it. Cairn
notification at about a city block. Merge means the same spot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The "Full Moon" label covers a 3.7-day window where illumination
ranges from 100% down to 85%. The shape rendered the shadow faithfully,
making a "Full Moon" look gibbous. Now snaps to a perfect circle above
95% and empty path below 5% (new moon).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ring

Map pins invisible — annotation managers created before Mapbox style
loaded, making their layers dead. Now guard on isStyleLoaded and
recreate managers after each style load.

Markers placed at walk start — LocationManagement.currentLocationRelay
was never updated during active recording, only before walk start.

Proximity replay on walk end — stopListening() kills the subscription
entirely instead of just clearing notified IDs (race with resetSession).

Proximity fires before walk starts — guard on isActiveStatus, reset
session on startRecording() so nearby items trigger on first GPS tick.

Cache not persisting — locally placed whispers/cairns now persist to
UserDefaults. invalidateLastFetch() forces fresh API data each walk.

Density filter hiding cairns — same-location whisper+cairn were
suppressed as "too close". Now only filters same-type annotations.

Icons — whispers rendered as wind SF Symbol in category color, cairns
as mountain.2 in moss, waypoints keep their user-chosen symbol. No
backing circles. Unique Mapbox image keys per whisper category color.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Mountain icon grows with tier, transitions from outline to filled at medium
- Breathing animation (large+), glow ring (great+), golden pulse (eternal)
- Kanji watermark per tier: 石 積 道 導 山 聖 永
- Progress bar toward next tier with stone count needed
- Relative timestamps for first/last stone placed
- Eternal tier shows 108 badge instead of progress bar
- Background gradient shifts warm with tier
- Merge radius 42m (was 21m) to account for GPS drift
- Sheet detent bumped to .medium, removed double padding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pass version param to bump marketing version for beta builds:
  gh workflow run testflight.yml --ref branch -f version=1.2.0

Omit version to keep current. Build number always increments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Auto-play whispers is a walk behavior, not an audio setting — moved
from Sounds to Practice alongside "Walk with the collective."

Trail Note row now uses connectRow with pencil.line icon to match
the other Connect card rows. Removed subtext for consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Annotation managers now explicitly positioned above pilgrim-route-layer
so whisper, cairn, and waypoint icons aren't hidden beneath the path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Keep feature branch version (1.2.0) and build number. Main only had
automated build number increments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@momentmaker momentmaker merged commit 9faadf3 into main Apr 7, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant