Files
crowd-funder-for-time-pwa/doc/CHANGES/PLAN-onboarding-by-interests.md
T

29 KiB

Plan: Onboarding by Interests

Goal

A person who opens the app to the Home feed for the first time (phone, web, or desktop) is asked one thing before anything else: what they are interested in, in their own words, with an optional "use my location". The app then shows them the people and projects whose descriptions are closest in meaning to what they typed, and closest in distance when they gave a location. After they have seen those results, and only once they are registered, a red dot on the profile tab (and a one-time banner) points them to the follow-up: save the interests and location to their public profile, and, on the phone apps, turn on a daily alert, text message first, so they hear when someone with matching interests joins or posts. Web and desktop have no New Activity alerts, so there the follow-up is the profile save alone.

In scope:

  1. The interests prompt replaces the three-page welcome dialog on every platform.
    • The Help page has a link to enter interests again, which opens the interests prompt. It sits above the link to the welcome pages.
    • The welcome pages open only from that Help link; they are not part of onboarding.
  2. The interests and location are saved on the phone as soon as the person enters them, registered or not, so they can be put in a public profile whenever the person chooses, including long after onboarding.
  3. A results screen for interest matches, reachable again later from Discover.
  4. A "next step" nudge that does not block the screen: a red dot on the profile tab, a banner that animates in once, and a card at the top of the Profile page that holds the follow-up actions: the profile save, and on the phone apps the alert. It appears only for registered identities, since every action needs registration.
  5. The follow-up actions reuse what the Profile page already does: save a profile with description and location, verify a phone and authorize SMS New Activity alerts, or authorize in-app New Activity alerts.

Out of scope: alerts on web and desktop; on-device embedding; changes to what the alerts contain; registration changes beyond text search on /similar (profile save and both alert channels keep requiring registration).

Current state

  • src/components/OnboardingDialog.vue is the welcome. It opens from HomeView.vue (initializeIdentity, when settings.finishedOnboarding is false), again from DiscoverView.vue and ProjectsView.vue, and re-opens on every visit to those pages until a button sets finishedOnboarding. Playwright web tests dismiss it through the closeOnboardingAndFinish test ID (test-playwright/testUtils.ts, importUserAndCloseOnboarding).
  • HelpView.vue has a "see the page-by-page help again" link (unsetFinishedOnboarding) that sets finishedOnboarding false and goes to Home, where the welcome dialog opens again.
  • src/views/HelpOnboardingView.vue (route help-onboarding) is a standalone instructions page for meeting onboarding; HelpView.vue holds the general help.
  • The router guard (src/router/index.ts) creates an identity on the first navigation, so Home never loads without an activeDid. Deep links land on their own routes (invite-one-accept, claim, confirm-gift, shared-photo, contact-import, …); the invite flow ends on contacts.
  • Matching lives on the Partner server (endorser-ch, src/api/controllers/partner-router.js; design in that repo's CHANGES/PLAN-interest-matching-architecture.md):
    • GET /api/partner/similar?from=text:<query>&to=profile|plan|both with an optional bounding box (minLocLat, minLocLon, maxLocLat, maxLocLon), minSimilarity, and limit (default 25, max 50). It returns { subjectType, subjectId, similarity, record } best first, with DIDs hidden the way the profile and plan searches hide them. A text: source needs a JWT from a registered DID (mainService.isMember) and is limited to EMBEDDING_QUERY_MAX_PER_MINUTE queries per DID (default 20). The query text is embedded in memory and not stored.
    • GET /api/partner/userProfile (the people list Discover uses) and GET /api/v2/report/plans / plansByLocation (the project lists) need a JWT but not registration. Both take a claimContents text filter and a bounding box, which is how Discover searches.
    • POST /api/partner/userProfile (description, locLat, locLon, optional second point) needs registration. Every saved profile is embedded, and the embedding is refreshed when the text changes.
    • Query text is embedded in-process through one serial inference queue (embedding.service.js, enqueue) shared with profile saves and the plan sweep. The queue has no length limit. The per-DID text limit is an in-memory map in partner-router.js (allowTextQuery). The server reads no client IP anywhere and does not set Express trust proxy.
    • Neither alertSearch handler (/api/partner/alertSearch, /api/v2/report/alertSearch) checks registration; a JWT is enough.
    • alertSearch returns profilesSimilar and plansSimilar: subjects whose text was embedded inside the window and whose similarity to the requester's saved profile is at least the spec's alertMinimumSimilarity. They are empty when the requester has no profile text. An optional similarLocation box restricts them.
  • This app's New Activity feature already counts those two lists (src/libs/newActivity.ts: "Profiles Like Yours", "Projects Matching Your Profile"), and the notification service (notification-wakeup-service, src/alertSearch/digest.ts) counts them into the "You have N new updates." push and text. So a saved profile with text is the only precondition for match alerts; no server or service change is needed for the alert itself.
  • Profile editing is on AccountViewView.vue ("Public Profile", a section shown only when isRegistered: a textarea, an "Include Location" checkbox, and a Leaflet map the person taps to place a point; saveProfile posts description, locLat, locLon). The app has no geolocation plugin and never calls navigator.geolocation; location is always chosen on a map (SearchAreaView.vue for the search box, settings.searchBoxes[0]).
  • Notification settings are also on AccountViewView.vue, under "New Activity Notification", and only for registered identities (the notify API refuses unregistered DIDs). Two channels:
    • In-App (notifyingNewActivityTime), shown only when showGeneralAdvanced is on because of iOS delivery problems (doc/sms-notifications.md), delivered by server push or on-device schedule (newActivityDelivery).
    • SMS (smsNotificationsEnabled, notifyingNewActivitySmsPhone, notifyingNewActivitySmsTime): toggleSmsEnabled → ChoiceButtonDialog ("See More Info" / "Enter Phone Number") → SmsVerificationDialog (US numbers, verification code) → promptSmsTimeAndEnable (PushNotificationPermission with timeOnly) → authorizeSmsAlertSearch. Passkey identities cannot use it.
  • No speech input exists anywhere in the app.
  • Settings are one row per account (src/db/tables/settings.ts); booleans and JSON columns are listed in PlatformServiceMixin._mapColumnsToValues, and a column is added with a migration in src/db-sql/migration.ts.
  • Platform detection: this.capabilities.isNativeApp and isMobile through PlatformServiceMixin (a computed property); the Capacitor service reports both true.

Design

State

One JSON settings column, onboardingState, following the newActivityCursors precedent:

{
  "stage": "interests",
  "interests": "vegetable gardening, teaching kids to code",
  "lat": 40.76,
  "lon": -111.89,
  "matchesShownAt": "2026-10-25T16:02:11Z",
  "bannerShownAt": null,
  "declinedAt": null,
  "welcomeTour": false
}

Stages, in order: interests (prompt pending) → matches (interests stored, results not shown, or shown without a reachable server) → save (results seen; the nudge is on) → done. A person who taps "Not now" on the prompt goes to done with declinedAt set. finishedOnboarding is set true whenever the stage becomes done, so everything that reads it keeps working.

The three welcome pages are not part of onboarding at any stage. HomeView, DiscoverView, and ProjectsView open OnboardingDialog only while welcomeTour is true. The Help page's "page-by-page help" link sets welcomeTour, and finishing or closing the dialog clears it. A finishedOnboarding of false, which a fresh install has until the stage reaches done, opens nothing by itself.

The Help page's "enter your interests again" link (above the welcome link) opens InterestsPromptDialog from Help, pre-filled with the kept interests and location. It does not change the stage unless the stage is done, in which case "Find matches" moves it to matches and the flow runs again.

The stage is per account, like every other setting. An identity that already has a profile with text on the server starts at done (a reinstall or a second device): the Home load checks GET /api/partner/userProfileForIssuer/:did once when the stage is interests.

Kept interests and location

interests, lat, and lon are written to onboardingState when the person taps "Find matches", before any network call, so neither the registration gates nor a server error can lose them. They are the draft of a public profile, and they outlive the onboarding stages:

  • They stay when the stage reaches done, including after "No thanks" on the Profile card, and after "Not now" on the prompt when the person had typed something before declining.
  • They stay for as long as the person is unregistered. The server refuses an unregistered profile save, so the phone copy is the only copy until someone registers them.
  • When a registered person has no saved profile, the Profile page's "Public Profile" editor (shown only to registered identities) pre-fills its textarea and map point from them and shows "Saved only on this device. Not public until you save your profile." with a "Forget" link. This is the path for a person who was unregistered during onboarding, and for anyone who declined the card.
  • Saving the profile clears them, since the server then holds the text. "Forget" clears them. Running the interest search again with edited text replaces them.
  • lat and lon hold the reading rounded to two decimals (about 1 km). The raw reading is never written.

Where and when the prompt opens

Every platform. HomeView sets data-onboarding-checked on #Content once the prompt has opened or was not due, and the Playwright helper closeOnboardingIfShown waits for it and closes the prompt only when it opened: test users with saved profile text never see it.

The prompt opens from HomeView only, in the same place OnboardingDialog opens (end of initializeIdentity, so the feed is loading underneath), when the stage is interests. Nothing else opens it, so an invite, a claim, a shared photo, or a contact import runs to its end without interruption; the person meets the prompt the first time they come to Home afterward.

No timer and no wait for a gesture. A timer fires while the person is reading or scrolling; waiting for a gesture interrupts the thing they chose to do. Opening with the feed, as the welcome dialog does, is the moment they expect to be greeted, and the feed stays visible behind the overlay as context.

It opens once. If dismissed with the close "x" (not "Not now"), the stage stays interests and the prompt returns on the next cold start of the app, not on the next visit to Home. "Not now" ends it.

The prompt (InterestsPromptDialog.vue)

One screen, replacing the three. Content:

  • Title: "What would you like to find?" Sub-line: "Tell us your interests, and we'll look for people and projects nearby that match."
  • A textarea with a placeholder of examples. A small hint under it: "Tip: tap the microphone on your keyboard to speak." (See Decisions for a dedicated mic button.)
  • A "Use my location" button. On tap, ask the OS for permission and read one position. On success, show "Near " with a "remove" link. On denial or failure, show "Pick on map" which opens a small map (the Leaflet setup from the Profile page) to tap a point. Location is optional; the search runs without it.
  • The privacy line, in full: "Your interests stay on this device. Searching sends them to the server to find matches, but the server does not store them. You can choose to add them to your public profile afterward." (See Decisions: the request's "does not leave the device" wording is not true of a server-side text search, and the line must not say it.)
  • Buttons: "Find matches" (primary; disabled until there is text) and "Not now" (secondary, data-testId="closeOnboardingAndFinish" so the existing test helper works if the flow is ever enabled on web).

"Find matches" stores interests and the point in onboardingState, sets the stage to matches, and navigates to the results view.

The results view (InterestMatchesView.vue, route interest-matches)

A dedicated view rather than a mode of DiscoverView, which already multiplies projects/people by anywhere/nearby/mapped and would gain a fourth axis.

  • Reads interests and the point from onboardingState, or from route query (text, lat, lon) so Discover can link to it later with a "Search by interests" entry.
  • Calls GET /api/partner/similar with from=text:<interests>, to=both, limit=50, and, when there is a point, a bounding box of about 50 km around it (±0.45° latitude; longitude scaled by cos(lat)). The box, not the point, is what leaves the device.
  • Shows two sections, People and Projects, best match first, each row with the description excerpt, the similarity as a short label ("strong", "good", "some"), and a tap-through to user-profile or project. When the box returns fewer than five rows, a second request without the box fills a "Farther away" section; the server's query cache serves the second request without another inference.
  • Registered or not, the person gets the same matches by meaning, from the same request (see Registration gates).
  • Error handling:
    • 429: "Too many searches right now; try again in a minute." with a retry button. The interests are already kept, so nothing is lost.
    • 503 (inference queue full): "Matching is busy; try again shortly." with a retry button.
    • Network failure: a retry button; stage stays matches.
  • On the first successful render, set matchesShownAt and the stage to save.

The nudge

Three pieces, all driven by onboardingState.stage === "save" together with settings.isRegistered. An unregistered person is never prompted to fill in a profile or turn on alerts: after the results the stage moves to save as usual, but the dot, banner, and card stay hidden. The first page load that finds isRegistered true (HomeView re-checks registration on each load) makes them appear, with the banner's one-time animation still unspent. The kept interests wait on the phone for that moment.

  1. Red dot on the profile tab. QuickNav.vue reads the stage once per launch into a shared reactive state (src/utils/onboardingNudge.ts), which the pages that change the stage or registration update, and renders a small red circle at the top-right of the circle-user icon. Every page shows QuickNav, so the pointer is everywhere and in nobody's way.
  2. One-time banner. A NextStepBanner.vue component, placed under TopMessage in HomeView, DiscoverView, ProjectsView, and InterestMatchesView. The first time any of those pages renders while the stage is save and bannerShownAt is null, it slides down from the top: "Want to hear when new matches appear? Save your interests and pick an alert →" on the phone apps, and "Let people with interests like yours find you: save your interests to your profile →" elsewhere. It holds for a few seconds while the profile tab's dot pulses (a CSS keyframe on the dot, triggered by a shared reactive flag), then collapses toward the tab. Tapping it goes to account. It sets bannerShownAt and never animates again; the dot stays.
  3. Next-step card on the Profile page. NextStepCard.vue at the top of AccountViewView.vue, above the identity section, shown while the stage is save. It carries the messaging for the follow-up and both actions:
    • Step 1, "Save to your public profile." Shows the interests text (editable) and, when a point exists, "Include location (rounded to about 1 km)" checked by default. "Save" calls the page's saveProfile path with the text and the rounded point, and also sets settings.searchBoxes[0] to the same box the search used when no search box exists yet, so Nearby works. Match alerts depend on this step; the card says so: "Alerts about matches use your saved profile."
    • Step 2, "Get alerted." On the phone apps only (offersAlerts(capabilities): native and mobile), since web and desktop have no Notifications section. Two choices, in this order:
      1. Text message (labelled "most reliable"): runs the existing toggleSmsEnabled → SmsVerificationDialog → time prompt → authorizeSmsAlertSearch sequence. Shown disabled with the reason when the identity uses a passkey or SMS is otherwise unavailable.
      2. In-app notification: runs the existing in-app enable path, with the note "may arrive late on iPhone". A link to help-notification-types sits under both.
    • "Done" and "No thanks" at the bottom; both set the stage to done, which clears the dot and the card. Completing every step shown (the save, plus an alert on the phone apps) also sets done. The card is a thin layer over methods AccountViewView already has; the notification section below it is unchanged, so a person who ignores the card finds the same toggles where they always were.

Welcome text

OnboardingDialog.vue keeps its three pages unchanged. It opens only from the Help page link, as described under State, and walks the Home → Discover → Projects sequence while welcomeTour is set.

Location

@capacitor/geolocation is added for "Use my location". Its Android implementation depends on play-services-location, so this is a Play Services dependency on top of the MLKit one (see AGENTS.md); on a device without Google services the call fails and the prompt falls back to "Pick on map". The point is used three ways: a ~50 km box for the search, a point rounded to two decimals (about 1 km) for the saved profile, and the same box for searchBoxes[0]. The raw reading is never stored.

Registration gates

Text search on GET /api/partner/similar opens to any JWT. Profile save and both alert channels keep requiring registration, so an unregistered person sees the same matches as a registered one, and their interests stay on the phone until someone registers them and they choose to save.

An unregistered DID costs nothing to mint, so the per-DID limit alone stops nobody. The server change adds these protections, in the order a request meets them:

  1. Per-IP limit on text queries, keyed by the client address: the IPv4 address, or the /64 prefix of an IPv6 address. Mobile carriers put many phones behind one IPv4 address (carrier-grade NAT), so the limit is a burst ceiling, not a per-person budget: default 60 per minute (EMBEDDING_QUERY_MAX_PER_MINUTE_PER_IP). The per-DID limit stays at 20 for registered DIDs and is 5 for unregistered ones.
  2. Correct client address. req.ip is the proxy's address unless Express trust proxy names the proxy. The setting is the proxy's address or hop count, from TRUST_PROXY, never true: with true, any client can set X-Forwarded-For and pick its own limit key. Unset, the per-IP limit keys on the connecting address, which is right when nothing fronts the API.
  3. Query cache. An LRU of recent query vectors keyed by the hash of the preprocessed text (default 1,000 entries). Repeated text, from one spammer or from many people typing "gardening", costs no inference.
  4. Bounded queue with priority. Text queries wait in their own queue capped at a few entries (EMBEDDING_QUERY_MAX_QUEUED, default 10); past that the request gets 503 at once instead of waiting. Profile saves and the sweep go ahead of queued text queries, so a flood delays only other searchers.

What this exposes: nothing an unregistered JWT cannot read already. The profile list is open to any JWT with DIDs hidden the same way, and plans are public claims. Search by meaning makes the same records easier to find, which is the point of the feature. Spam does not get in through this route: it is read-only, and publishing anything (profile, claim, SMS number) keeps its registration gate.

Decisions made

  1. Privacy wording. Use the 'privacy line' above.
  2. Registration gates. Open text search on /similar to unregistered identities, with the protections under Registration gates. Keep the gates on profile save and on both alert channels. Interests and location stay on the phone until the person is registered and chooses to save them.
  3. Speech input. Keyboard dictation with a hint (for the first release: zero dependencies, works on both platforms as they are). Not a dedicated mic button through @capacitor-community/speech-recognition, which needs microphone and speech permissions and an on-device recognizer service (Google's on most Android phones; absent on some FOSS devices).
  4. Geolocation dependency. Accept @capacitor/geolocation and its play-services-location dependency (with the map fallback).
  5. In-app choice visibility. The card shows the in-app option even when showGeneralAdvanced is off. SMS is listed first. The card appears only for registered identities, so neither choice needs a registration message.
  6. Default alert time. Pre-fill the time prompt with 9:00 local and show "change".
  7. Re-prompt rule. After the "x" close, return on the next cold start.

Work

endorser-ch

  • /similar: drop the registration check for text:; per-DID limit 20 registered / 5 unregistered.
  • Per-IP text-query limit (IPv4 address or IPv6 /64), with TRUST_PROXY feeding Express trust proxy; document the setting for each deployment.
  • Query-vector LRU cache keyed by preprocessed-text hash.
  • Separate, capped text-query queue behind profile and sweep work; 503 when full.
  • Tests in test/controller-partner-9-similar.js: unregistered text query succeeds; per-IP limit with a spoofed X-Forwarded-For ignored when the proxy is not trusted; cache hit skips inference; full queue returns 503.
  • Deploy to the test server before the app's results view depends on it.

This app: state and gating

  • Migration adding onboardingState TEXT to settings, and the column in the Settings type as a JSON string read with parseOnboardingState, like newActivityCursors (not in _mapColumnsToValues).
  • src/libs/onboarding.ts: the state type, stage transitions, the bounding-box helper (point + radius → minLocLat … ), the rounding helper, and offersAlerts(capabilities). Unit tests in src/test/.
  • HomeView.initializeIdentity: with stage interests, check for an existing server profile, then open InterestsPromptDialog; open OnboardingDialog instead during a welcome tour.
  • HomeView, DiscoverView, ProjectsView: open OnboardingDialog only while welcomeTour is set; clear it when the dialog finishes or closes.
  • HelpView.vue: an "enter your interests again" link above the welcome link, opening InterestsPromptDialog pre-filled; the welcome link sets welcomeTour.
  • Web and desktop: the same prompt, results, and nudge, with the card's alert step and the banner's alert wording only on the phone apps.

This app: prompt and results

  • InterestsPromptDialog.vue: textarea, location button with permission flow and map fallback, privacy line, "Find matches", "Not now".
  • @capacitor/geolocation: install, Android permissions (ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION), iOS NSLocationWhenInUseUsageDescription, Podfile entry; note the Play Services dependency in doc/android-firebase-gms.md.
  • iOS pod install for CapacitorGeolocation (updates Podfile.lock; scripts/build-ios.sh runs it through cap sync) — next: run on a machine with CocoaPods (Trent)
  • src/libs/partnerServer.ts: fetchSimilar(params) typed client for /api/partner/similar, with the 429, 503, and network cases mapped to a small result type.
  • InterestMatchesView.vue + route interest-matches (+ deep-link schema entry with optional text, lat, lon): two sections, "Farther away" fill, error states, stage → save on first render.
  • DiscoverView.vue: a "Search by interests" link to the view, so the feature is reachable after onboarding.

This app: nudge

  • QuickNav.vue: red dot on the profile tab while the stage is save and the identity is registered; pulse class driven by a shared reactive flag.
  • NextStepBanner.vue: one-time animated banner, registered identities only; placed in Home, Discover, Projects, and the results view; sets bannerShownAt.
  • NextStepCard.vue in AccountViewView.vue: Step 1 save (text + rounded point + searchBoxes[0]), Step 2 alerts with SMS first and in-app second, reasons when disabled, "Done" / "No thanks"; rendered only for registered identities; clears interests from onboardingState only on a successful save.
  • AccountViewView.vue "Public Profile": with no saved profile, pre-fill from the kept interests and point, with the "Saved only on this device" note and "Forget".
  • Default time pre-fill in PushNotificationPermission when opened from the card (per Decision 6).

This app: text, docs, tests

  • doc/usage-guide.md and HelpNotificationTypesView.vue: mention that match alerts come from the saved profile text.
  • Unit tests: stage transitions, kept-interests lifetime (kept on done, "Not now", and while unregistered; cleared on save and "Forget"), bbox and rounding helpers, fetchSimilar error mapping.
  • Playwright web: closeOnboardingIfShown (waits for data-onboarding-checked, closes the prompt only if it opened) in place of direct closeOnboardingAndFinish clicks; no close on Discover, where nothing opens.
  • Manual script in doc/onboarding-by-interests-manual-test.md, phones and web: fresh install → prompt → deny location → map pick → matches → banner → dot → card → SMS path on a registered account; invite deep link on a fresh install → no prompt until Home; unregistered account → matches by meaning → no dot, banner, or card → register → dot and banner appear → card offers "Save" → profile saved with those interests; "No thanks" on the card → Profile editor pre-filled later; Help → welcome pages walk Home, Discover, Projects once and stop.
  • CHANGELOG entry; bump with -beta.

Rejected

A timer before the prompt

A delay of a few seconds lands while the person is reading the feed or mid-scroll, and the length is arbitrary. The welcome dialog's timing (with the feed load) is the one moment the person expects to be addressed.

Waiting for the first gesture

The first tap or scroll is an action the person chose; a modal at that moment cancels it. It also makes the prompt's appearance feel caused by whatever they touched.

Extending DiscoverView with a "matches" mode

Discover already crosses projects/people with anywhere/nearby/mapped; a similarity mode would add a fourth axis and a different result shape (similarity-ranked, two types at once). A separate view is smaller and can be linked from Discover.

Running the search on the device

Requires the vector export and an on-device copy of the embedding model (a few hundred megabytes), which the server plan schedules as a later phase. It is the only way to make "never leaves the device" literally true, and it is not available for this release.

Blocking the next step with another modal after the results

The request rules this out: the person is in a search flow and the follow-up must wait at the edge of the screen until they choose it.

Keeping the welcome pages as the web and desktop onboarding

The first release of this plan limited the interests prompt to the phone apps and left web and Electron on the welcome pages. Closed 2026-10-31: every platform gets the interests prompt, and only the alert step stays phone-only, because web and desktop have no Notifications section.

Dropping the three welcome pages entirely

Their content is the only in-app explanation of the feed, Discover, and Projects tabs, so they stay, reachable from Help.

Keyword search for unregistered identities

The open people and project lists filter on claimContents, which needs the person's words to appear in a record. A sentence of interests rarely does, so the results would be thin exactly when the first impression matters.

Opening profile save or SMS to unregistered identities

Profile save would let anyone publish to the list every user reads, which is where spam would land. SMS verification for an unregistered DID would let anyone make the notify API text arbitrary numbers at the project's expense (SMS pumping). Match alerts for unregistered people would need both, so they wait for registration.