diff --git a/.env.test b/.env.test
index 55f9a316..72a4d5ac 100644
--- a/.env.test
+++ b/.env.test
@@ -5,8 +5,7 @@ VITE_LOG_LEVEL=info
# iOS doesn't like spaces in the app title.
TIME_SAFARI_APP_TITLE="TimeSafari_Test"
-VITE_APP_SERVER=https://test.`
-# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not
+VITE_APP_SERVER=https://test.timesafari.app
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not production).
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
diff --git a/AGENTS.md b/AGENTS.md
index 51f6d292..c362d99f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,3 +20,7 @@ Full details, incident history, and F-Droid notes: `doc/android-firebase-gms.md`
## Android Build — MLKit Barcode Scanner
`@capacitor-mlkit/barcode-scanning` depends on `com.google.android.gms:play-services-code-scanner`, which merges `com.google.android.gms.version` into the APK manifest. This is a known long-term issue for strict FOSS/F-Droid builds. For now, the dependency is accepted; barcode scanning simply will not work on GMS-less devices (it fails gracefully at scan time, not at startup). Do not add additional GMS/Firebase dependencies without explicitly acknowledging this trade-off.
+
+## Android Build — Capacitor Geolocation
+
+`@capacitor/geolocation` depends on `com.google.android.gms:play-services-location`, an accepted second GMS dependency. On GMS-less devices "Use my location" fails and the interests prompt falls back to picking a point on a map. See `doc/android-firebase-gms.md`.
diff --git a/BUILDING.md b/BUILDING.md
index 8ca52add..30a673fb 100644
--- a/BUILDING.md
+++ b/BUILDING.md
@@ -1154,7 +1154,7 @@ export GEM_PATH=$shortened_path
###### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
```bash
-cd ios/App && xcrun agvtool new-version 76 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.10;/g" App.xcodeproj/project.pbxproj && cd -
+cd ios/App && xcrun agvtool new-version 77 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.11;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
@@ -1446,8 +1446,8 @@ The recommended way to build for Android is using the automated build script:
##### 1. Bump the version in package.json, then update these versions & run:
```bash
-perl -p -i -e 's/versionCode .*/versionCode 76/g' android/app/build.gradle
-perl -p -i -e 's/versionName .*/versionName "1.4.10"/g' android/app/build.gradle
+perl -p -i -e 's/versionCode .*/versionCode 77/g' android/app/build.gradle
+perl -p -i -e 's/versionName .*/versionName "1.4.11"/g' android/app/build.gradle
```
##### 2. Build
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abea2c9a..64e72a9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.4.11] - 2026.11.01
+### Added
+- The first visit to the feed asks for interests (and an optional location) instead of showing the three welcome pages, then shows the people and projects closest in meaning, nearest first.
+- After the matches, a registered person sees a red dot on the profile tab, a one-time banner, and a card on the Profile page to save the interests as their public profile; on Android and iOS the card also offers a text-message or in-app alert.
+### Changed
+- The three welcome pages open only from Help.
+
+
## [1.4.10] - 2026.10.17
### Changed
- New Activities were heavily refactored and enhanced to match alertSearch endpoints with more results, cursors, etc.
diff --git a/README.md b/README.md
index 74746e18..76f0c598 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# TimeSafari.app - Crowd-Funder for Time - PWA
+# Giftopa.me AKA TimeSafari.app - Crowd-Funder for Time - PWA
-[Time Safari](https://timesafari.org/) allows people to ease into collaboration: start with expressions of gratitude
+[Giftopia](https://giftopia.tech/) allows people to ease into collaboration: start with expressions of gratitude
and expand to crowd-fund with time & money, then record and see the impact of contributions.
## Roadmap
@@ -408,6 +408,6 @@ Gifts make the world go 'round!
* [Many tools & libraries](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/src/branch/master/package.json#L10) such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
* [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)
* [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg)
-* Time Safari logo assisted by [DALL-E in ChatGPT](https://chat.openai.com/g/g-2fkFE8rbu-dall-e)
+* TimeSafari logo assisted by [DALL-E in ChatGPT](https://chat.openai.com/g/g-2fkFE8rbu-dall-e)
* [DiceBear](https://www.dicebear.com/licenses/) and [Avataaars](https://www.dicebear.com/styles/avataaars/#details) for human-looking identicons
* Some gratitude prompts thanks to [Develop Good Habits](https://www.developgoodhabits.com/gratitude-journal-prompts/)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 6594dbb1..e5722773 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -39,8 +39,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 76
- versionName "1.4.10"
+ versionCode 77
+ versionName "1.4.11"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -64,8 +64,13 @@ android {
}
buildTypes {
release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ // R8 shrinking/optimization: Play Vitals "code optimization" (DEX) score.
+ // Plugin keep rules come from each library's consumer rules (Capacitor
+ // core keeps every com.getcapacitor.Plugin subclass); app rules are in
+ // proguard-rules.pro.
+ minifyEnabled true
+ shrinkResources true
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
// Only sign if we have the signing config
if (signingConfigs.release.storeFile != null) {
signingConfig signingConfigs.release
diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle
index 68734a4b..787643c9 100644
--- a/android/app/capacitor.build.gradle
+++ b/android/app/capacitor.build.gradle
@@ -15,6 +15,7 @@ dependencies {
implementation project(':capacitor-camera')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-filesystem')
+ implementation project(':capacitor-geolocation')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index f1b42451..f31e3fe6 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -14,8 +14,8 @@
# Uncomment this to preserve the line number information for
# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
+-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
+-renamesourcefileattribute SourceFile
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index fea4baa4..812795ff 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -99,6 +99,8 @@
+
+
diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json
index 28240b47..941a658d 100644
--- a/android/app/src/main/assets/capacitor.plugins.json
+++ b/android/app/src/main/assets/capacitor.plugins.json
@@ -23,6 +23,10 @@
"pkg": "@capacitor/filesystem",
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
},
+ {
+ "pkg": "@capacitor/geolocation",
+ "classpath": "com.capacitorjs.plugins.geolocation.GeolocationPlugin"
+ },
{
"pkg": "@capacitor/preferences",
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle
index 6fd65f99..e9a1410c 100644
--- a/android/capacitor.settings.gradle
+++ b/android/capacitor.settings.gradle
@@ -20,6 +20,9 @@ project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacito
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
+include ':capacitor-geolocation'
+project(':capacitor-geolocation').projectDir = new File('../node_modules/@capacitor/geolocation/android')
+
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
diff --git a/doc/CHANGES/PLAN-device-alertsearch-notifications.md b/doc/CHANGES/PLAN-device-alertsearch-notifications.md
new file mode 100644
index 00000000..5fa9dafc
--- /dev/null
+++ b/doc/CHANGES/PLAN-device-alertsearch-notifications.md
@@ -0,0 +1,235 @@
+# Plan: On-Device New Activity Notifications Through alertSearch
+
+## Goal
+
+A person who picks on-device delivery for in-app New Activity notifications
+(`newActivityDelivery: "device"`) gets the same count the server push would
+send, at the same time, computed on the phone:
+
+1. The device runs the alertSearch query against both servers
+ (`/api/v2/report/alertSearch` on Endorser, `/api/partner/alertSearch` on
+ Partner) with the person's starred projects and search area.
+2. It counts from the same start as the New Activity screen, and it moves that
+ start forward the way the notification service does, so a notification
+ never repeats what the person already saw and never skips what they have
+ not.
+3. The notification reads "You have N new updates." and opens New Activity
+ with the run's start cursors as `c`, exactly like a server push.
+4. The notification service holds nothing for this person.
+5. A change to those APIs, to what counts, or to the notification text is a
+ change to configuration that the app's TypeScript sends. It needs no
+ plugin release and no Java or Swift change.
+
+## Current state
+
+- `TimeSafariNativeFetcher` (`android/app/src/main/java/app/timesafari/`,
+ `ios/App/App/`) is app code, not plugin code. The daily-notification-plugin
+ calls it five minutes before the notify time (`buildDualScheduleConfig`,
+ chained dual in plugin 3.x), and what it returns becomes the notification.
+- The fetcher posts to Endorser `plansLastUpdatedBetween` with the starred plan
+ IDs, so it reports starred-project changes only: roughly the server's
+ `trackedPlanUpdates` list, one of eight.
+- Its inputs come from `configureNativeFetcher` (API base URL, active DID, a
+ pool of day-scoped JWTs) and `updateStarredPlans` (plan IDs in plugin
+ storage: SharedPreferences on Android, UserDefaults on iOS).
+- Every change to what the fetcher asks or counts is a Java and a Swift change.
+- The read state lives in the app's SQLite settings (`newActivityCursors`,
+ `src/libs/newActivity.ts`), which native code does not read.
+
+## Design
+
+The plugin becomes a scheduled request runner that knows nothing about
+alertSearch. It does four things: run a job on its schedule, hold the JWTs
+that authorize the job's requests, execute the requests a job describes, and
+keep the raw results for the app to read. `TimeSafariNativeFetcher` is retired.
+
+A notification has to be decided when the app is not running, so no
+TypeScript runs at that moment. The plugin therefore interprets responses, but
+only through a small, fixed vocabulary that the job supplies as data: where
+the cursor is, whether there is another page, what to count, and what to say.
+
+### The job
+
+JS registers a job with one call (`configureRequestJob`), and replaces it the
+same way. Shape, with the alertSearch values:
+
+```json
+{
+ "schemaVersion": 1,
+ "id": "newActivity",
+ "schedule": { "notifyCron": "0 9 * * *", "fetchLeadMinutes": 5 },
+ "allowedHosts": ["api.endorser.ch", "partner-api.endorser.ch"],
+ "requests": [
+ {
+ "id": "endorser",
+ "method": "POST",
+ "url": "https://api.endorser.ch/api/v2/report/alertSearch",
+ "body": { "planHandleIds": ["…"], "location": { "minLocLat": 40 } },
+ "auth": { "jwtPool": "endorser" },
+ "cursor": {
+ "value": "48213",
+ "requestField": "afterCursor",
+ "responseField": "nextAfterCursor"
+ },
+ "paging": { "hasMoreField": "hasMore", "maxPages": 20 },
+ "count": [
+ { "path": "data.claims" },
+ { "path": "data.personalPlanContributions" },
+ { "path": "data.trackedPlanUpdates" },
+ { "path": "data.trackedPlanClaims" },
+ { "path": "data.plansNearby" }
+ ]
+ },
+ {
+ "id": "partner",
+ "method": "POST",
+ "url": "https://partner-api.endorser.ch/api/partner/alertSearch",
+ "body": { "location": { "minLocLat": 40 } },
+ "auth": { "jwtPool": "endorser" },
+ "cursor": {
+ "value": "912-48213",
+ "requestField": "afterCursor",
+ "responseField": "nextAfterCursor"
+ },
+ "paging": { "hasMoreField": "hasMore", "maxPages": 20 },
+ "count": [
+ { "path": "data.profilesNearby" },
+ { "path": "data.profilesSimilar", "distinctBy": "rowId" },
+ { "path": "data.plansSimilar", "distinctBy": "handleId" }
+ ]
+ }
+ ],
+ "commit": "allComplete",
+ "notification": {
+ "title": "TimeSafari",
+ "body": { "one": "You have 1 new update.", "other": "You have {count} new updates." },
+ "skipWhenZero": true,
+ "deepLink": "https://giftopia.me/deep-link/new-activity?c={start.endorser}.{start.partner}"
+ }
+}
+```
+
+What the plugin does with it on each scheduled run:
+
+- **Requests.** It runs each request from its stored cursor, putting the
+ cursor in the body under `requestField`, and pages while the response's
+ `hasMoreField` is true, up to `maxPages`, passing each page's
+ `responseField` as the next cursor.
+- **Seeding.** A request with no cursor value makes one call, keeps only the
+ returned cursor, and counts nothing.
+- **Counting.** The count is the number of array items at each `count` path
+ across all pages, distinct by `distinctBy` where given, summed over requests.
+- **Commit.** With `allComplete`, the cursors advance only when every request
+ completed. Otherwise they all stay, and nothing is shown, so a source that
+ times out skips the day and the next run covers the whole window.
+- **Notification.** It renders the `body` template with the count and the
+ run's start cursors, and shows nothing for zero when `skipWhenZero` is set.
+- **Hosts.** It sends nothing to a host outside `allowedHosts`, so stored JWTs
+ cannot be pointed elsewhere.
+- **Versions.** It refuses a job whose `schemaVersion` it does not know. Only a
+ new word in the vocabulary, such as a new way to count, needs a plugin
+ release.
+
+### Credentials
+
+The plugin holds JWT pools by name (`jwtPool`), each a set of day-scoped
+delegated JWTs (`BACKGROUND_JWT_SECONDS_PER_DAY`,
+the pool `configureNativeFetcher` passes), and picks one valid at request time.
+JS refills a pool whenever it configures the job. The pools live in the
+platform's protected store (Android Keystore-backed storage, iOS Keychain),
+not in SharedPreferences or UserDefaults.
+
+### Results
+
+The plugin keeps the last runs of each job, pruned to a few:
+
+```json
+{
+ "jobId": "newActivity",
+ "ranAt": "2026-10-26T15:00:12Z",
+ "completed": true,
+ "notified": true,
+ "count": 4,
+ "start": { "endorser": "48213", "partner": "912-48213" },
+ "next": { "endorser": "48301", "partner": "930-48301" },
+ "responses": { "endorser": [{ "…": "raw page JSON" }], "partner": [] }
+}
+```
+
+JS reads them with `getRequestJobResults` and interprets them with the code it
+already has (`src/libs/alertSearch.ts`, `src/services/newActivity.ts`). A shape
+change on a server is then handled in TypeScript alone. The raw pages also let
+New Activity show what the last run found without asking the servers again.
+
+### State handoff
+
+JS stays the owner of the read state:
+
+- It sends the cursors in the job at the points where the push channel
+ uploads: turning on (latest cursors, `newActivityStartState`), a new time
+ (cursors unchanged), and the re-upload after a New Activity visit
+ (`refreshNotificationAuthorizations`, where `"device"` becomes a destination
+ next to `fcm` and `sms`).
+- It records the cursors it sent as `uploaded.device` in
+ `NewActivityCursorState`, so the visit logic that skips an unchanged channel
+ works unchanged.
+- When New Activity loads, it reads the latest result's `next` cursors, the
+ same way it treats the service as having moved on after a run.
+
+### One count definition
+
+The `count` paths and `distinctBy` keys in the job are the device's definition
+of the count. They come from the same TypeScript the app uses for its own
+counts (`NEW_ACTIVITY_BUCKETS`, the Partner deduplication by `rowId` and
+`handleId`), so the device and the screen cannot disagree. A Jest test builds
+the job and checks it against the notification service's
+`ALERT_SEARCH_DIGEST_BUCKETS` fixtures. The count logic exists only in
+TypeScript, not in Java or Swift.
+
+### Other notes
+
+- A source that times out should skip the day.
+
+- The plugin keeps no daily cap: its schedule fires once a day, as the
+ service's stored hour does on its side. A second notification the same day
+ takes a new time later that day, which is the person's own deliberate
+ reset, with no server or messaging cost for a cap to guard.
+
+- The Daily Reminder keeps its own schedule and is untouched.
+
+## Work
+
+### daily-notification-plugin
+
+- [ ] `configureRequestJob` / `cancelRequestJob` (Android and iOS): store the
+ job, schedule the fetch at `fetchLeadMinutes` before `notifyCron`.
+- [ ] Request runner: cursor in body, paging, seeding, `allComplete` commit,
+ host allowlist, `schemaVersion` check.
+- [ ] Counting by path with `distinctBy`; notification template with `one` /
+ `other`, `{count}`, `{start.}`; `skipWhenZero`; deep link.
+- [ ] Named JWT pools in protected storage; pick a JWT valid at request time.
+- [ ] `getRequestJobResults`, keeping and pruning the last runs.
+- [ ] Plugin tests: a job run against a stub HTTP server, on both platforms.
+- [ ] Keep `configureNativeFetcher` / `updateStarredPlans` until this app no
+ longer calls them, then remove them.
+- [ ] Release, then bump the dependency here.
+
+### This app, TypeScript
+
+- [ ] Build the job from settings: server URLs, starred IDs, search area,
+ cursors, bucket paths, time, text.
+- [ ] Configure it on turn-on, a new time, and the visit refresh in device
+ mode; refill the JWT pool each time; record `uploaded.device`.
+- [ ] Read results on New Activity load and take their `next` cursors.
+- [ ] Switch device mode from `scheduleDualNotification` to the job, and
+ cancel it on turn-off and on a switch to server mode.
+- [ ] Drop "For now it checks only your starred projects." from the delivery
+ choice in `AccountViewView.vue`.
+- [ ] Jest tests: the job's count definition against the service fixtures, and
+ results read back into `NewActivityCursorState`.
+
+### This app, native
+
+- [ ] Remove `TimeSafariNativeFetcher.java` and `.swift`, and their
+ registration in `MainActivity` and `AppDelegate`, once device mode runs
+ on the job.
diff --git a/doc/CHANGES/PLAN-onboarding-by-interests.md b/doc/CHANGES/PLAN-onboarding-by-interests.md
new file mode 100644
index 00000000..91211b28
--- /dev/null
+++ b/doc/CHANGES/PLAN-onboarding-by-interests.md
@@ -0,0 +1,557 @@
+# 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:&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:
+
+```json
+{
+ "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:`, `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" at 0.85
+ and up, "weak" at 0.81 and up, none below), and a tap-through to
+ `user-profile` or `project`. Only strong matches show by default; the rest
+ sit in a collapsed "Show N weaker matches" section, and with no strong
+ matches the view says so above that section, then says how they would
+ hear about one (`noStrongFollowUp` in `src/libs/onboarding.ts`): a
+ registration notice when unregistered; a link to the follow-up card
+ (`/account?nextStep=1`, shown regardless of stage) when the server profile
+ has no text or, on the phone apps, no daily alert is on; otherwise a note
+ that the alert is on, or on the web that alerts are in the phone apps.
+ 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
+
+- [x] `/similar`: drop the registration check for `text:`; per-DID limit 20
+ registered / 5 unregistered.
+- [x] Per-IP text-query limit (IPv4 address or IPv6 /64), with `TRUST_PROXY`
+ feeding Express `trust proxy`; document the setting for each deployment.
+- [x] Query-vector LRU cache keyed by preprocessed-text hash.
+- [x] Separate, capped text-query queue behind profile and sweep work; 503
+ when full.
+- [x] 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
+
+- [x] 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`).
+- [x] `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/`.
+- [x] `HomeView.initializeIdentity`: with stage `interests`, check for an
+ existing server profile, then open `InterestsPromptDialog`; open
+ `OnboardingDialog` instead during a welcome tour.
+- [x] `HomeView`, `DiscoverView`, `ProjectsView`: open `OnboardingDialog` only
+ while `welcomeTour` is set; clear it when the dialog finishes or closes.
+- [x] `HelpView.vue`: an "enter your interests again" link above the welcome
+ link, opening `InterestsPromptDialog` pre-filled; the welcome link sets
+ `welcomeTour`.
+- [x] 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
+
+- [x] `InterestsPromptDialog.vue`: textarea, location button with permission
+ flow and map fallback, privacy line, "Find matches", "Not now".
+- [x] `@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)
+- [x] `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.
+- [x] `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.
+- [x] `DiscoverView.vue`: a "Search by interests" link to the view, so the
+ feature is reachable after onboarding.
+
+### This app: nudge
+
+- [x] `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.
+- [x] `NextStepBanner.vue`: one-time animated banner, registered identities
+ only; placed in Home,
+ Discover, Projects, and the results view; sets `bannerShownAt`.
+- [x] `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.
+- [x] `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".
+- [x] Default time pre-fill in `PushNotificationPermission` when opened from
+ the card (per Decision 6).
+
+### This app: text, docs, tests
+
+- [x] `doc/usage-guide.md` and `HelpNotificationTypesView.vue`: mention that
+ match alerts come from the saved profile text.
+- [x] 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.
+- [x] 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.
+- [x] 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.
+- [x] 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.
diff --git a/doc/CHANGES/PLAN.md b/doc/CHANGES/PLAN.md
new file mode 100644
index 00000000..548fb00c
--- /dev/null
+++ b/doc/CHANGES/PLAN.md
@@ -0,0 +1,10 @@
+
+## Tasks
+
+- [ ] Onboarding by interests on mobile — next: deploy the endorser-ch `/similar` change to the test server, run iOS `pod install`, then the manual script `doc/onboarding-by-interests-manual-test.md` on devices; see [PLAN-onboarding-by-interests.md](PLAN-onboarding-by-interests.md) (Trent)
+- [ ] On-device New Activity notifications through alertSearch — next: plugin request-job runner (`configureRequestJob`), then the app's job builder; see [PLAN-device-alertsearch-notifications.md](PLAN-device-alertsearch-notifications.md) (Trent)
+- [ ] Default `apiServer` in every settings fallback. `$accountSettings()`
+ (`src/utils/PlatformServiceMixin.ts`) returns the caller's `defaults` (often
+ `{}`) on its early-return and error paths, so `apiServer` can be undefined;
+ `HomeView` works around this on its own. This gap exists independently of
+ degraded mode.
diff --git a/doc/CHANGES/PLAN-new-activity-alert-search.md b/doc/CHANGES/history/PLAN-new-activity-alert-search.md
similarity index 86%
rename from doc/CHANGES/PLAN-new-activity-alert-search.md
rename to doc/CHANGES/history/PLAN-new-activity-alert-search.md
index 7c365f6a..35aba77b 100644
--- a/doc/CHANGES/PLAN-new-activity-alert-search.md
+++ b/doc/CHANGES/history/PLAN-new-activity-alert-search.md
@@ -1,5 +1,12 @@
# Plan: New Activity Screen on alertSearch
+## Status
+
+Built in the app, the notification service and endorser-ch. The last change,
+leaving a person's own gives and offers out of their
+`personalPlanContributions`, is in endorser-ch's Unreleased CHANGELOG section
+and ships with its next release; Playwright spec 50 depends on it.
+
## Goal
The New Activity screen (`/new-activity`, `src/views/NewActivityView.vue`)
@@ -16,7 +23,7 @@ list's length.
| Bucket | Server | What it holds | Needs |
|---|---|---|---|
| `claims` | endorser `/api/v2/report/alertSearch` | Claims by others with you as `toEntity` or `fromEntity` (offers, gives, every other claim type) | — |
-| `personalPlanContributions` | endorser | Gives and offers to or from projects you issued or are agent of (your own claims included) | — |
+| `personalPlanContributions` | endorser | Gives and offers by others to or from projects you issued or are agent of | — |
| `trackedPlanUpdates` | endorser | Edits to starred projects | `planHandleIds` |
| `trackedPlanClaims` | endorser | Gives and offers to or from starred projects | `planHandleIds` |
| `plansNearby` | endorser | Projects created or edited in the search area | `location` |
@@ -69,16 +76,22 @@ Stored as JSON in one settings column, `newActivityCursors`
- **Visit window**, one cursor per server. The summary counts every bucket from
its start, the same window the notification service counts from once the
- visit is uploaded to it.
+ visit is uploaded to it, except a bucket whose list was opened after that
+ start was recorded: it counts from the end of that list (`summaryStart` in
+ `src/libs/newActivity.ts`, `countSummaryActivity` in
+ `src/services/newActivity.ts`).
- **Bucket window**, one per bucket, on that bucket's server. A detail page
- lists everything from its start. A bucket never opened starts where the
+ lists everything since the end of its last load, so opening it marks the
+ bucket read; reopening it shows nothing new until more arrives, and "Show
+ earlier" lists what it showed before. A bucket never opened starts where the
summary does, so its first detail count equals the summary's.
- **Uploaded**, the cursors last sent to each notification channel.
-A window records where a load counted from (`from`), where it ended (`to`) and
-when the visit began (`at`). A load less than 30 minutes after `at` is the same
-visit and counts from the same `from`, so moving between the summary and a
-detail page keeps the counts; a later load starts a new visit from the last
+A window records where a load counted from (`from`) and when that cursor was
+recorded (`fromSeenAt`), where it ended (`to`) and when (`seenAt`), and when
+the visit began (`at`). A summary load less than 30 minutes after `at` is the
+same visit and counts from the same `from`, so reloading the summary keeps the
+counts of unopened lists; a later load starts a new visit from the last
`to`. "Mark unread from this item" does not exist, since no cursor points at an
item.
@@ -140,8 +153,8 @@ summary's order, from the same eight alertSearch buckets
- A bucket counts from the end of whichever load the person saw last: the
summary's visit or that bucket's own list (`unseenStart` in
`src/libs/newActivity.ts`, ordered by each window's `seenAt`). It never
- uses a visit's start, so a list just opened shows no tile, even inside the
- 30-minute visit in which the summary keeps showing its count.
+ uses a visit's start, so a summary just loaded shows no tiles, even inside
+ the 30-minute visit in which the summary keeps showing its counts.
- Buckets on one server with the same start share one request, so with no
list opened since the summary, Home makes one request per server.
- Home writes the cursor state only to seed it on the first load ever; it
@@ -275,15 +288,14 @@ App (`crowd-funder-for-time-pwa`):
where New Activity starts in parallel with the feed, Affirm Delivery waits
for the offer's project, a WebAssembly explanation on the start-up error
screen, test warm-up, retry and reporter).
-- [ ] Release the app after endorser-ch is deployed, since detail rows read
- `claim` from `alertSearch` — next: release (Trent)
+- [x] Release the app after endorser-ch is deployed, since detail rows read
+ `claim` from `alertSearch`.
Notification service (`notification-wakeup-service`):
- [x] Service changes 1–6, `c` in the SMS link and FCM data, tests, README,
CHANGELOG.
-- [ ] Deploy to test and production — next: deploy, or tick this if done
- (Trent)
+- [x] Deploy to test and production.
endorser-ch:
@@ -292,27 +304,25 @@ endorser-ch:
sends) gets `toEntity`, so it reaches the owner's
`personalPlanContributions` and `trackedPlanClaims`; migration V23 fills
existing offers without moving `changeSeq`; test, CHANGELOG.
-- [ ] Commit the `isPartOf` change: `CHANGELOG.md`, `sql/README.md`,
+- [x] Commit the `isPartOf` change: `CHANGELOG.md`, `sql/README.md`,
`sql/V23__offer_is_part_of_to_entity.sqlite3`, `claim.service.js`,
- `endorser.db.service.js`, `test/controller-endorser-6-plans-totals.js` —
- next: commit (Trent)
-- [ ] Deploy to test, then production, running Flyway on the main DB so V23
- applies — next: deploy (Trent)
-- [ ] Leave a person's own gives and offers out of their
- `personalPlanContributions` (the query includes them when a person
- contributes to their own project) — next: implement after the deploys above (agent)
- - endorser-ch: add `AND j.issuer != ?`, bound to the requesting DID, to
- both halves of the `UNION` in `jwtsForUserPlanContributions`
+ `endorser.db.service.js`, `test/controller-endorser-6-plans-totals.js`.
+- [x] Deploy to test, then production, running Flyway on the main DB so V23
+ applies.
+- [x] Leave a person's own gives and offers out of their
+ `personalPlanContributions`:
+ - endorser-ch: `AND j.issuer != ?`, bound to the requesting DID, on both
+ halves of the `UNION` in `jwtsForUserPlanContributions`
(`endorser.db.service.js`), as `jwtsWithDidAfterId` does.
- endorser-ch test in `controller-endorser-6-plans-totals.js`: user 1's own
offer to user 1's plan is absent from user 1's
`personalPlanContributions`, and user 2's offer is present.
- - App spec 50 "Record an offer": record the offer as User 1
- (`importUserFromAccount(page, "01")`) on a project User 0 owns, then
- switch to User 0 to check the `personalPlanContributions` tile and list.
- "Affirm delivery" then gets User 1 as giver and the project as recipient.
- - endorser-ch CHANGELOG entry; the notification service needs no change,
- since its counts come from the same query.
+ - App spec 50 "Record an offer": User 0 seeds New Activity on Home, User 1
+ records the offer on a project User 0 owns, then User 0 checks the
+ `personalPlanContributions` tile and list. "Affirm delivery" gets User 1
+ as giver and the project as recipient.
+ - endorser-ch CHANGELOG entry (Unreleased); the notification service needs
+ no change, since its counts come from the same query.
## Out of scope
@@ -345,3 +355,7 @@ Closed decisions:
- 2026-10-16: detail pages query `alertSearch` themselves and do not reuse
Home's rows; an in-memory hand-off would be a second path that every
entry other than a fresh, uncapped Home tile skips.
+- 2026-10-24: `personalPlanContributions` leaves out the requester's own
+ gives and offers, matching `claims`, which already left out the requester's
+ own claims. Before this, a person who offered to their own project saw it
+ counted as new activity. The plan was closed and moved to history.
diff --git a/doc/CHANGES/PLAN-startup-db-failure-recovery.md b/doc/CHANGES/history/PLAN-startup-db-failure-recovery.md
similarity index 70%
rename from doc/CHANGES/PLAN-startup-db-failure-recovery.md
rename to doc/CHANGES/history/PLAN-startup-db-failure-recovery.md
index 5c952b75..e6100704 100644
--- a/doc/CHANGES/PLAN-startup-db-failure-recovery.md
+++ b/doc/CHANGES/history/PLAN-startup-db-failure-recovery.md
@@ -1,5 +1,17 @@
# Plan: Startup Database Failure — Diagnostics and Degraded Mode
+## Status
+
+Closed without building Parts A–C. The startup failure this plan prepared for
+had a root cause, and the fix in `PLAN-startup-db-fix-crash.md` removed it: the
+SQLite plugin's configuration key, migration recording, and Android backup
+rules. A user who saw the error on every launch started normally after
+installing a build with that fix.
+
+The recovery screen from commit d6ccf0e4 remains the answer for any other
+database failure. Parts A–C below are kept as a design to pick up if startup
+database failures are reported again; nothing in them has been built.
+
## Goal
When the local database cannot be opened, a user should (1) get a useful
@@ -27,23 +39,25 @@ Requirements:
- `src/utils/startupError.ts` recognizes that error by its message and hands it
to `StartView` through `sessionStorage`.
- Three places catch it and redirect to `/start`: the router `errorHandler`
- and `beforeEach` guard (`src/router/index.ts:344`, `:363-454`), and the Vue
- global error handler (`src/main.common.ts`).
+ and `beforeEach` guard (`src/router/index.ts`), and the Vue global error
+ handler (`src/main.common.ts`).
- The guard calls `retrieveAccountDids()` for every route except the
identity-setup routes, so with the database down every route except
`/account`, `/test` and `/logs` lands on `/start`.
-- `StartView` shows the recovery screen: Try Again, Email/Copy Details,
- "Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
- (`resetDatabase()`, which copies the file aside before deleting it).
+- `StartView` shows the recovery screen: Try Again, Email Details, Copy
+ Details, "Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
+ (`resetDatabase()`, which copies the file aside before deleting it). When the
+ browser cannot run WebAssembly, the screen says so and points to the
+ settings that block it.
- API reads work without an identity: `getHeaders()`
- (`src/libs/endorserServer.ts:430`) omits the Authorization header when there
+ (`src/libs/endorserServer.ts`) omits the Authorization header when there
is no DID.
- Every path that creates, saves, or signs with an identity reads the `secret`
and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`,
`retrieveFullyDecryptedAccount`), so no action that needs a DID can work while
the database is down.
-## Part A: One-tap diagnostics report
+## Part A: One-tap diagnostics report (not built)
Contents of the report:
@@ -61,10 +75,10 @@ screen shows the exact payload and a Send button; the email option stays for
users who decline.
The database-existed flag needs a native-side or `Filesystem.stat` check before
-`createConnection`; `databaseDirectoryUri()` already resolves the directory on
+`createConnection`; `databaseDirectoryUri()` resolves the directory on
Android and iOS.
-## Part B: Degraded mode
+## Part B: Degraded mode (not built)
- Change the router guard so that a database startup error records the error
and lets read-only routes through: home feed, projects, project and claim
@@ -77,13 +91,14 @@ Android and iOS.
confirm, invite, contacts) with that same reason, instead of the
"someone must register you" text an unregistered user sees.
- Default `apiServer` in every settings fallback. `$accountSettings()`
- (`src/utils/PlatformServiceMixin.ts:1071-1136`) returns `defaultSettings` or
- the caller's `defaults` (often `{}`) on its early-return and error paths, so
- `apiServer` can be undefined; `HomeView` works around this on its own.
+ (`src/utils/PlatformServiceMixin.ts`) returns the caller's `defaults` (often
+ `{}`) on its early-return and error paths, so `apiServer` can be undefined;
+ `HomeView` works around this on its own. This gap exists independently of
+ degraded mode. (Moved to PLAN.md)
- Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a
Copy → Start Over.
-## Part C (optional): An identity that does not need SQLite
+## Part C (optional): An identity that does not need SQLite (not built)
Key generation is pure (`generateSeed` → `deriveAddress` → `newIdentifier`).
In degraded mode the app could generate an identity, require the user to write
@@ -99,6 +114,8 @@ encryption setting (`androidIsEncryption: false`) in effect.
## Open questions
+These stay open for whoever picks up Parts A–C.
+
- Which server and endpoint receive diagnostics, and how long reports are kept.
- Whether to quarantine a corrupt database automatically ("file is not a
database", "malformed") and start fresh. Automatic quarantine trades a
@@ -109,7 +126,8 @@ encryption setting (`androidIsEncryption: false`) in effect.
user's identity on a new phone; it also gives the Google backup a copy of the
keys together with the secret that decrypts them. The backup rules
(`android/app/src/main/res/xml/backup_rules.xml`,
- `data_extraction_rules.xml`) include it.
+ `data_extraction_rules.xml`) include it and exclude only the plugin's
+ encrypted preference files.
## Rejected
@@ -119,6 +137,8 @@ encryption setting (`androidIsEncryption: false`) in effect.
## Checklist
+Parts A–C are deferred, not scheduled.
+
- [ ] A1. Decide the diagnostics endpoint and payload retention.
- [ ] A2. Capture "database file existed before first connection" during startup.
- [ ] A3. Build the report payload and the Send Diagnostics button with preview.
@@ -127,3 +147,17 @@ encryption setting (`androidIsEncryption: false`) in effect.
- [ ] B3. Default `apiServer` in all `$accountSettings()` fallbacks.
- [ ] B4. Reorder the recovery screen.
- [ ] C1. (Optional) Preferences-backed identity with import on recovery.
+
+## History
+
+- 2026-10-10: commit d6ccf0e4 added the failed step and cause to the startup
+ error, the `startupError.ts` hand-off to `StartView`, and the recovery screen
+ (Try Again, Email/Copy Details, Save a Copy, Start Over).
+- 2026-10-10: commit f41a807a fixed the root cause (see
+ `PLAN-startup-db-fix-crash.md`) and wrote this plan. Tagged 1.4.8; the CHANGELOG
+ records it under 1.4.9 as "Startup DB error for new installations".
+- 2026-10-17: commit d2cdd578 added the WebAssembly explanation to the recovery
+ screen (CHANGELOG 1.4.10).
+- After 1.4.9: a user who had the startup error on every launch reported
+ normal startup on a build with the fix. The plan was closed with Parts A–C
+ unbuilt.
diff --git a/doc/CHANGES/history/fix-startup-db-crash.md b/doc/CHANGES/history/PLAN-startup-db-fix-crash.md
similarity index 97%
rename from doc/CHANGES/history/fix-startup-db-crash.md
rename to doc/CHANGES/history/PLAN-startup-db-fix-crash.md
index 80001d20..5b6055d9 100644
--- a/doc/CHANGES/history/fix-startup-db-crash.md
+++ b/doc/CHANGES/history/PLAN-startup-db-fix-crash.md
@@ -10,8 +10,7 @@ Error" on every launch:
```
Reinstalling did not help. The failure never appeared on developer devices
-installed through adb or Android Studio. Full symptom report:
-`startup-db-error-symptoms.md`.
+installed through adb or Android Studio.
## Causes
@@ -107,7 +106,8 @@ the app on both platforms.
## Follow-up
The one-tap diagnostics report and a degraded mode for users without a working
-database are planned in `doc/CHANGES/PLAN-startup-db-failure-recovery.md`.
+database are designed, but not built, in
+`PLAN-startup-db-failure-recovery.md` in this directory.
## History
diff --git a/doc/android-firebase-gms.md b/doc/android-firebase-gms.md
index c3aa14f3..85cc977f 100644
--- a/doc/android-firebase-gms.md
+++ b/doc/android-firebase-gms.md
@@ -68,6 +68,14 @@ Practical impact:
This is an accepted trade-off. Removing it would require either forking the plugin or introducing a `foss` product flavor that excludes the MLKit plugin entirely — work to undertake if/when F-Droid submission is planned.
+## Known GMS dependency: Capacitor Geolocation
+
+`@capacitor/geolocation` (the interests prompt's "Use my location" on the phone apps) depends on `com.google.android.gms:play-services-location` on Android. Like the MLKit scanner, it is present regardless of `-PfirebaseEnabled`.
+
+On a device without Google Play Services the location request fails, and the prompt falls back to "Pick on map", where the person taps a point on a Leaflet map. Location is optional: the search runs without one. Only a point rounded to two decimals (about 1 km) is kept, and only a box of about 50 km around it is sent to the server.
+
+The same trade-off applies as for MLKit: removing it would mean a `foss` flavor that drops the plugin and keeps only the map.
+
## Incident: June 2026
Jose Olarte III's `notify-api` branch placed a production `google-services.json` in `android/` to test Firebase Cloud Messaging. The branch was never merged to `master`, but because the file is gitignored it persisted on disk after switching branches. At the time, the Gradle conditional activated Firebase based on file presence alone (no opt-in flag), so all subsequent local builds embedded Firebase and required Google Play Services. This silently broke APK/Aurora/Zapstore distribution.
diff --git a/doc/onboarding-by-interests-manual-test.md b/doc/onboarding-by-interests-manual-test.md
new file mode 100644
index 00000000..ab6b0e04
--- /dev/null
+++ b/doc/onboarding-by-interests-manual-test.md
@@ -0,0 +1,121 @@
+# Manual Test: Onboarding by Interests
+
+The app asks for interests on the first visit to the feed, shows matches
+from the partner server's `/api/partner/similar`, and then nudges a
+registered person to save a profile and, on Android and iOS, to pick an
+alert. Sections 1-7 run on the phone apps; section 8 covers web and desktop.
+Design: `doc/CHANGES/PLAN-onboarding-by-interests.md`. The Playwright suite
+covers the web dismissal path (`closeOnboardingIfShown`).
+
+## Setup
+
+- A partner server that allows text search on `/similar` for unregistered
+ identities, with some profiles and projects that have descriptions.
+- A build pointed at that server (Profile → Advanced → Partner Server).
+- "Fresh install" means uninstalling the app first, so settings are empty.
+- A registered identity to import for the registered runs, and a US phone
+ number that can receive the verification text.
+
+## 1. Fresh install, registered, denied location, SMS
+
+1. Install, open, and import the registered identity (or have it registered
+ from another device) and go to the feed.
+2. The prompt "What would you like to find?" opens over the feed; the three
+ welcome pages do not.
+3. Type interests. Tap "Use my location" and deny permission. "Pick on map"
+ appears with a map; tap a point. "Near , " shows two decimals.
+4. Tap "Find matches". The results view shows People and Projects with only
+ "strong" matches (similarity 0.85 and up). Below them, "Show N weaker
+ matches" is collapsed; tapping it reveals the rest, "weak" rows labeled and
+ lower rows unlabeled. With no strong matches, "No strong matches yet"
+ shows above the collapsed row, followed by how they'd hear about one:
+ unregistered, a notice to find someone to register them; registered
+ without a saved profile, or on a phone without a daily alert, a link to
+ the Profile page's follow-up card (`/account?nextStep=1`); with both, a
+ note that the daily alert is on; on the web with a saved profile, a note
+ that alerts are in the phone apps. With fewer than five nearby, "Farther
+ away" appears.
+5. Go to the feed. The banner slides down once, the profile tab's red dot
+ pulses, and the banner collapses. Visit Discover and Projects: no banner;
+ the dot stays.
+6. Tap the profile tab. The "Hear about new matches" card is at the top with
+ the interests filled in and "Include location" checked.
+7. Tap "Save". The profile saves; "Public Profile" below shows the text and
+ the point. Profile → Search Area shows a box around the point if there was
+ none before.
+8. Tap "Text message". Enter the phone number, enter the code, and accept the
+ time prompt, which starts at 9:00 AM. The card closes, and the dot is gone
+ on every page.
+
+## 2. Invite deep link on a fresh install
+
+1. Fresh install. Open an invite link (`invite-one-accept`) on the phone.
+2. The invite flow runs to the contacts page without the prompt.
+3. Tap the feed tab. The prompt opens now.
+
+## 3. Unregistered identity, then registration
+
+1. Fresh install with a new, unregistered identity. Go to the feed.
+2. Enter interests with "Use my location" allowed; "Find matches" shows
+ matches the same way as for a registered identity.
+3. No banner, no dot, and no card on the Profile page. "Public Profile" is
+ not shown (it needs registration).
+4. Have someone register the identity. Open the feed: after the
+ registration check, the banner animates and the dot appears.
+5. The card on the Profile page offers "Save" with the interests typed in
+ step 2. Save; the profile holds those interests.
+
+## 4. "No thanks", then the profile editor
+
+1. With the card showing on a registered identity that has no saved profile,
+ tap "No thanks". The card and dot go away.
+2. Reload the Profile page. "Public Profile" is pre-filled with the
+ interests and point, with "Saved only on this phone. Not public until you
+ save your profile." and "Forget".
+3. "Forget" empties the editor and the note goes away; reloading the page
+ keeps it empty.
+
+## 5. Close, "Not now", and Help
+
+1. Fresh install. When the prompt opens, close it with the "x". Visit
+ another tab and return to the feed: no prompt. Force-quit and reopen the
+ app: the prompt opens again.
+2. Type something and tap "Not now". The prompt does not return on later
+ cold starts.
+3. Help → "enter your interests again" opens the prompt with the text kept
+ from step 2. "Find matches" runs the flow again (results, then banner and
+ dot for a registered identity).
+4. Help → "see the page-by-page help again" opens the welcome on the feed.
+ "Show me more!" goes to Discover with its page, then Projects with its
+ page. "Let's go!" closes it. Visiting those pages again opens nothing.
+
+## 6. Existing profile on a reinstall
+
+1. Fresh install, then import an identity that already has a saved public
+ profile with text.
+2. Go to the feed. No prompt opens (the app found the server profile).
+
+## 7. Server limits
+
+1. Search many times within a minute from the results view ("Change", then
+ "Find matches"). After the limit, "Too many searches right now; try again
+ in a minute." shows with "Try again".
+2. With the server's `EMBEDDING_QUERY_MAX_QUEUED=0`, a new uncached text
+ gives "Matching is busy; try again shortly."
+3. In airplane mode, the results view shows the connection message and
+ "Try again"; the interests are still in Help's prompt afterward.
+
+## 8. Web and desktop
+
+1. In a fresh browser profile (or a private window), open the app. The
+ prompt opens over the feed with no keyboard-microphone tip, and the
+ privacy line says "this device".
+2. "Use my location": the browser asks for permission. Deny it, and "Pick on
+ map" appears.
+3. With a registered identity, "Find matches", then return to the feed: the
+ banner reads "Let people with interests like yours find you…", and the dot
+ appears.
+4. The Profile card has only "Save to your public profile", with no alert
+ step. "Save" closes the card and the dot.
+5. Help → "enter your interests again" is present, above the page-by-page
+ help link, and opens the prompt pre-filled.
diff --git a/doc/usage-guide.md b/doc/usage-guide.md
index 596da455..83e3e1e3 100644
--- a/doc/usage-guide.md
+++ b/doc/usage-guide.md
@@ -74,6 +74,13 @@ Companion videos will also describe end-to-end workflows for the end-user.
This section covers application usage for people who will use TimeSafari as intended. It is a
simplified guide illustrating how to gain value from using TimeSafari.
+The first visit to the feed asks what you are interested in, in your own words, with an optional
+location. The app shows the people and projects whose descriptions are closest in meaning, nearest
+first. The interests stay on your device until you save them to your public profile. On the
+Android and iOS apps, a daily New Activity alert (in-app or text message) can report new matches;
+those matches come only from the saved public profile text, so an alert about them needs a saved
+profile.
+
\pagebreak
# 2 - Software Developers
diff --git a/index.html b/index.html
index 271390be..ffd7c7cd 100644
--- a/index.html
+++ b/index.html
@@ -8,10 +8,10 @@
- Time Safari
+ Giftopia
-