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

18 KiB
Raw Blame History

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.

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):

  • HTTP client and paging in src/libs/alertSearch.ts.
  • Bucket registry and cursor state in src/libs/newActivity.ts; loads in src/services/newActivity.ts.
  • Settings column newActivityCursors, migration 012_add_newActivityCursors_to_settings.
  • NewActivityView.vue as the summary, with the c comparison.
  • NewActivityBucketView.vue, NewActivityRow.vue, PlanChangeDiff.vue, route /new-activity/:bucket.
  • Re-upload on visit; latest cursors from the enable toggles.
  • c in the deep-link query schema; FCM taps open New Activity with c.
  • First load seeds cursors and shows nothing; no afterId sent.
  • 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.
  • Jest tests (src/test/newActivity.test.ts); Playwright specs 50 and 55 updated.
  • Playwright specs 50 and 55 pass against local servers in Chromium and Firefox.
  • CHANGELOG entry (1.4.10).
  • 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).
  • Release the app after endorser-ch is deployed, since detail rows read claim from alertSearch.

Notification service (notification-wakeup-service):

  • Service changes 1–6, c in the SMS link and FCM data, tests, README, CHANGELOG.
  • Deploy to test and production.

endorser-ch:

  • claim on claims rows of /api/v2/report/alertSearch, CHANGELOG.
  • 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.
  • 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.
  • Deploy to test, then production, running Flyway on the main DB so V23 applies.
  • 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.