Files
crowd-funder-for-time-pwa/doc/CHANGES/history/PLAN-new-activity-alert-search.md
T
2026-10-24 22:14:17 -06:00

362 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`)
shows the same total the daily push or text reported, broken down by kind of
activity. Each kind opens a page with the exact list and count for that kind.
When a page's count differs from the number the person was told, the page
says why.
## The two endpoints
alertSearch has two halves. Each returns **lists**, not counts; a count is a
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 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` |
| `profilesNearby` | partner `/api/partner/alertSearch` | Profiles created or edited in the search area | `location` |
| `profilesSimilar` | partner | Profiles close in meaning to your profile, best first, at most 50, not paged | a profile with text |
| `plansSimilar` | partner | Projects close in meaning to your profile, best first, at most 50, not paged | a profile with text |
Paging facts (from the endorser-ch handlers):
- Each server has one cursor covering all of its buckets. Endorser cursors
look like `1234567`; partner cursors look like `45678-1234567` (partner
position, then main position). Clients treat both as opaque text, compared
only for equality.
- `afterCursor` returns changes after it, oldest first; `beforeCursor` returns
changes before it, newest first. Both together bound a window.
- A page holds at most 50 rows per list, all cut at the same change point;
`hasMore` is one flag for the whole page.
- A row's `changeSeq` is not a cursor, so no cursor points at a single item.
## How the notification service counts
`notification-wakeup-service` runs one daily alertSearch per channel and
identity:
- `src/alertSearch/retrieve.ts` calls both endpoints with the channel's stored
`afterCursor`, `planHandleIds` and `location`, pages forward while `hasMore`
(at most 20 pages), and merges the pages.
- `src/alertSearch/digest.ts` totals all eight buckets.
- Cursors are stored per channel (`fcm` and `sms` in separate tables) and
advance after every run in which both sources complete, sent or not. Each
daily count therefore covers only changes after the previous run, whether or
not the person opened the app in between, so no item is counted in two
notifications. Rejected: subtracting the previous day's count, which would
subtract it twice.
- An upload carrying a cursor for a server replaces that server's stored
cursor, and the first run counts from it. An upload without one leaves the
stored cursor alone.
- A run with no stored cursor for a server only stores that server's cursor
and counts nothing from it. The app avoids this by sending cursors with
every upload that turns a channel on (see "Starting from the latest
change").
- The SMS body is `Gift Economies: you have N new updates.
https://giftopia.me/deep-link/new-activity?c=<start cursors> Reply STOP to
end.` The FCM payload carries title, body, `type: "alert_search"` and `c`.
## Read state
Stored as JSON in one settings column, `newActivityCursors`
(`NewActivityCursorState` in `src/libs/newActivity.ts`):
- **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, 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 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`) 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.
The first load ever has no cursor. It only records where to start and shows
nothing as new:
1. The app sends each server a request with no cursor and no ID. The server
answers with its most recent rows and its latest cursors
(`nextAfterCursor`).
2. The app ignores the rows: every count is 0 and every list is empty
(`countServer` and `loadBucket` in `src/services/newActivity.ts` count
nothing without a start).
3. It stores a visit ending at those cursors (`seedIfNeeded`, `seededState`),
with a start time long past, so the next load counts from them.
Home and the detail pages seed before counting; the summary's own first load
does the same, since the end of a load with no start becomes the start of
the next visit. Turning on notifications seeds too, with the cursors it
uploads. Seeding needs both servers to answer; otherwise nothing is stored,
nothing shows as new, and the next load tries again. A detail page's "Show
earlier" still lists what came before the seed.
Arriving through a notification link with no visit yet counts from the
link's `c` instead.
Rejected: starting endorser after the oldest `lastAcked*JwtId` (`afterId`) so
items unread on the earlier screen stay unread; the app would then depend on
the ID path that endorser-ch plans to retire.
Opening a detail page also moves the `lastAcked*JwtId` read markers
(`claims` → offers to you, `personalPlanContributions` → offers to your
projects, `trackedPlanUpdates` → starred changes), which the Recent Offers
pages use to mark the last-read offer.
## Summary screen
- One alertSearch call per server from the visit start, paging forward while
`hasMore` up to 4 pages; past that a count shows "+".
- Same inputs as the notification service: `planHandleIds` from
`settings.starredPlanHandleIds`, `location` from the first
`settings.searchBoxes` entry converted from `BoundingBox` to
`minLocLat/maxLocLat/minLocLon/maxLocLon`.
- The total at the top is the sum of all eight buckets, as the service totals
them, so with no activity since the run it equals the notified number.
- With no search box, the location rows link to `/search-area`; with no
starred projects, the tracked rows show a hint.
- A server that fails shows "?" on its buckets and the error; the other
server's counts appear, and the visit does not move.
## Home screen
The Home screen shows one tile per bucket with something unseen, in the
summary's order, from the same eight alertSearch buckets
(`countUnseenActivity` in `src/services/newActivity.ts`).
- Each tile shows the count (with "+" past the page limit) and the bucket's
label, and opens that bucket's detail page with `?expected=` set to the
count. "View All New Activity For You" opens the summary.
- 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 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
never starts a visit otherwise, and never uploads to the notification
service.
- Tile test IDs are `newActivityNumber-<bucket>`.
## Detail pages
One view, `/new-activity/:bucket` (`NewActivityBucketView.vue`), calls the
same endpoint from the bucket start and shows only that bucket, so its count
is exact. A per-bucket legacy endpoint cannot match it: `/report/offers`
holds only offers, while `claims` holds every claim type that names you.
- The heading shows its own count. The summary and the Home tiles pass their
count as `?expected=`; when they differ, a note says how many came before
the tapped count started, or that the list has fewer than it showed.
Rejected: handing Home's rows to the detail page, which saves one request
while this query runs anyway after a deep link, a relaunch, a capped count
or a different bucket start.
- "Show more" continues forward; "Show earlier" pages back with
`beforeCursor` from the bucket start, listing items already seen.
- Rows (`NewActivityRow.vue`) link to `/claim/:id`, `/project/:handleId`,
`/user-profile/:rowId`; similarity matches show their match percentage.
- `trackedPlanUpdates` keeps the before/after diff (`PlanChangeDiff.vue`),
fed by `plansLastUpdatedBetween` for the page's handle IDs with `afterId`
just before the oldest edit, because alertSearch plan rows carry no earlier
claim.
- The `claims` and `personalPlanContributions` pages link to
`RecentOffersToUserView` and `RecentOffersToUserProjectsView`.
- endorser-ch returns `claim` on `claims` rows, so the page can say what was
offered or given.
## Re-registering notifications on visit
When the summary (`/new-activity`) opens a new visit and the push or text
channel is on, the app re-uploads that channel's alert authorization in the
background, after the counts are on screen: a fresh
JWT batch replacing the unused one, with the visit's end cursors, the starred
handle IDs and the search area. The next daily count then starts from this
visit and uses the person's current starred projects and area.
- It reuses `mintAlertAuthorizationBatch` and the POST
`/notifications/alert-authorization` and POST
`/notify-sms/alert-authorization` routes
(`src/services/notifications/newActivityRefresh.ts`).
- It is skipped inside the same visit, and when the channel already counts
from this visit's start and nothing is new.
- Detail pages and the Home screen never upload. A detail page's window is
narrower than the service's single cursor per server, so uploading it would
drop the other buckets' unseen items from the next notification.
- Changing the text time re-uploads without cursors, so nothing waiting for
the next text is dropped.
Notification service changes this needs:
1. `AlertAuthorizationRequestBody` accepts optional `endorserAfterCursor`,
`partnerAfterCursor`, `planHandleIds` and `location`; the routes write all
four into that channel's cursor table.
2. The daily run sends the stored `planHandleIds` and `location` to
alertSearch.
3. `replaceUnusedBatch` skips days that already hold a consumed JWT.
Without that, a re-upload after today's run installs an unused JWT for
today, and the scheduler runs and pushes a second time the same day (SMS
has a per-phone daily cap; FCM has none).
4. `profilesSimilar` and `plansSimilar` join the total on both sides:
`mergePartner` keeps them (deduplicated by profile `rowId` and plan
`handleId`) and `ALERT_SEARCH_DIGEST_BUCKETS` lists them.
5. Cursors advance only when both sources complete, so a half-failed run
repeats the whole window on its retry.
6. A run with no stored cursor for a source seeds that cursor and counts
nothing from it.
## Starting from the latest change
Turning on push or text notifications (the toggles in `AccountViewView.vue`)
sends the latest cursors with the first upload, so the first notification
counts only what happens after the person turned it on. The app gets those
cursors from one alertSearch call per server, made just before the upload,
and stores them as its visit when it has none. Each server stands alone: one
that does not answer gets the end of the last visit instead, and with neither
its cursor is left out of the upload. Service change 6 covers only that last
case.
## Notification links carry the window
Each run holds the cursors it counted from (`startCursors` on the daily
result). The text and the push carry them, so the app can tell whether it is
looking at the same window the person was told about.
- SMS URL: one parameter joining both servers' start cursors with `.`:
`https://giftopia.me/deep-link/new-activity?c=1234567.45678-1234567`.
That adds about 25 characters; the body stays inside one 160-character
segment for counts under 10,000. A first run counts nothing and sends no
text.
- FCM: the same `c` in the data payload, and a tap routes to
`/new-activity?c=…`.
- App: `deepLinkQuerySchemas["new-activity"]` accepts optional `c`. When `c`
differs from the visit start, the summary also counts from `c` and compares
totals: fewer since the visit means "you may have already seen some of what
it counted"; more means "this includes N from before that notification".
- Optional, for an exact replay: also send the run's end cursors and offer a
"Show what the notification counted" view using `afterCursor` = start and
`beforeCursor` = end. That doubles the URL addition and can push the text
past one segment.
## Steps
App (`crowd-funder-for-time-pwa`):
- [x] HTTP client and paging in `src/libs/alertSearch.ts`.
- [x] Bucket registry and cursor state in `src/libs/newActivity.ts`; loads in
`src/services/newActivity.ts`.
- [x] Settings column `newActivityCursors`, migration
`012_add_newActivityCursors_to_settings`.
- [x] `NewActivityView.vue` as the summary, with the `c` comparison.
- [x] `NewActivityBucketView.vue`, `NewActivityRow.vue`, `PlanChangeDiff.vue`,
route `/new-activity/:bucket`.
- [x] Re-upload on visit; latest cursors from the enable toggles.
- [x] `c` in the deep-link query schema; FCM taps open New Activity with `c`.
- [x] First load seeds cursors and shows nothing; no `afterId` sent.
- [x] Home tiles for all eight buckets, each opening its detail page, counted
from `unseenStart`; the legacy `/report/offers`,
`offersToPlansOwnedByMe` and starred-changes counts removed from Home.
- [x] Jest tests (`src/test/newActivity.test.ts`); Playwright specs 50 and 55
updated.
- [x] Playwright specs 50 and 55 pass against local servers in Chromium and
Firefox.
- [x] CHANGELOG entry (1.4.10).
- [x] Test and start-up fixes found while running the specs (Home records
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).
- [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.
- [x] Deploy to test and production.
endorser-ch:
- [x] `claim` on `claims` rows of `/api/v2/report/alertSearch`, CHANGELOG.
- [x] An Offer that names its plan in `itemOffered.isPartOf` (as the app
sends) gets `toEntity`, so it reaches the owner's
`personalPlanContributions` and `trackedPlanClaims`; migration V23 fills
existing offers without moving `changeSeq`; test, CHANGELOG.
- [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`.
- [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": 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
- The native background fetcher (`TimeSafariNativeFetcher`) keeps its own
read state.
## History
Closed findings from reading the notification service before these changes
(2026-10-15):
- The schedulers called `runDailyAlertSearch(userId, now, {})`, and the upload
body had no field for `location` or `planHandleIds`, so `trackedPlanUpdates`,
`trackedPlanClaims`, `plansNearby` and `profilesNearby` were always empty: the
reported total was `claims` + `personalPlanContributions`.
- `profilesSimilar` and `plansSimilar` were dropped when partner pages merged.
- Each source's cursor advanced on its own, so when one source completed and
the other failed, the retry started past the completed source's rows and
never counted them.
- A first run counted the server's newest page, up to 50 rows per bucket of old
activity.
Closed decisions:
- 2026-10-16: the first load seeds cursors and shows nothing, replacing the
`afterId` handoff from the oldest `lastAcked*JwtId`, so the app sends
`alertSearch` only cursors and endorser-ch can retire the ID path. A person
upgrading with unread offers sees them under "Show earlier" and on the
Recent Offers pages, not as new.
- 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.