425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
/**
|
|
* New Activity: the alertSearch buckets, and the cursors that say what the
|
|
* person has seen.
|
|
*
|
|
* Read state has two layers, stored together as JSON in the settings column
|
|
* `newActivityCursors`:
|
|
* - The visit window, one cursor per server. The summary counts every bucket
|
|
* from its start, the same point the notification service counts from once
|
|
* the visit is uploaded to it.
|
|
* - A window per bucket, on that bucket's server. A detail page counts from the
|
|
* end of its last load, so opening it marks the bucket read.
|
|
*
|
|
* A visit started less than {@link SAME_VISIT_MS} ago is the same visit: the
|
|
* next summary load counts from the same start, so reloading the summary does
|
|
* not zero the counts. A bucket opened since that start counts from the end of
|
|
* its own list instead ({@link summaryStart}).
|
|
*/
|
|
|
|
import type { AlertSearchCursor } from "@/interfaces/alertSearch";
|
|
import type {
|
|
AlertSearchRequestParams,
|
|
AlertSearchServer,
|
|
AlertSearchServerData,
|
|
} from "./alertSearch";
|
|
|
|
export const NEW_ACTIVITY_BUCKETS = [
|
|
"claims",
|
|
"personalPlanContributions",
|
|
"trackedPlanUpdates",
|
|
"trackedPlanClaims",
|
|
"plansNearby",
|
|
"profilesNearby",
|
|
"profilesSimilar",
|
|
"plansSimilar",
|
|
] as const;
|
|
|
|
export type NewActivityBucket = (typeof NEW_ACTIVITY_BUCKETS)[number];
|
|
|
|
/** What a bucket needs before it can hold anything. */
|
|
export type NewActivityPrerequisite = "none" | "starred" | "location";
|
|
|
|
export interface NewActivityBucketInfo {
|
|
key: NewActivityBucket;
|
|
server: AlertSearchServer;
|
|
/** Heading on the summary and the detail page; `n` is the count. */
|
|
label: (n: number) => string;
|
|
/** One sentence for the detail page. */
|
|
description: string;
|
|
needs: NewActivityPrerequisite;
|
|
}
|
|
|
|
function plural(n: number, one: string, many: string): string {
|
|
return n === 1 ? one : many;
|
|
}
|
|
|
|
export const NEW_ACTIVITY_BUCKET_INFO: Record<
|
|
NewActivityBucket,
|
|
NewActivityBucketInfo
|
|
> = {
|
|
claims: {
|
|
key: "claims",
|
|
server: "endorser",
|
|
label: (n) => plural(n, "Claim About You", "Claims About You"),
|
|
description:
|
|
"Offers, gifts, and other claims by others that name you as giver or recipient.",
|
|
needs: "none",
|
|
},
|
|
personalPlanContributions: {
|
|
key: "personalPlanContributions",
|
|
server: "endorser",
|
|
label: (n) =>
|
|
plural(
|
|
n,
|
|
"Gift or Offer on Your Projects",
|
|
"Gifts & Offers on Your Projects",
|
|
),
|
|
description:
|
|
"Gifts and offers to or from projects you created or are agent for.",
|
|
needs: "none",
|
|
},
|
|
trackedPlanUpdates: {
|
|
key: "trackedPlanUpdates",
|
|
server: "endorser",
|
|
label: (n) =>
|
|
plural(
|
|
n,
|
|
"Starred Project With Changes",
|
|
"Starred Projects With Changes",
|
|
),
|
|
description: "Edits to projects you starred.",
|
|
needs: "starred",
|
|
},
|
|
trackedPlanClaims: {
|
|
key: "trackedPlanClaims",
|
|
server: "endorser",
|
|
label: (n) =>
|
|
plural(
|
|
n,
|
|
"Gift or Offer on Starred Projects",
|
|
"Gifts & Offers on Starred Projects",
|
|
),
|
|
description: "Gifts and offers to or from projects you starred.",
|
|
needs: "starred",
|
|
},
|
|
plansNearby: {
|
|
key: "plansNearby",
|
|
server: "endorser",
|
|
label: (n) => plural(n, "Project Nearby", "Projects Nearby"),
|
|
description: "Projects created or edited in your search area.",
|
|
needs: "location",
|
|
},
|
|
profilesNearby: {
|
|
key: "profilesNearby",
|
|
server: "partner",
|
|
label: (n) => plural(n, "Profile Nearby", "Profiles Nearby"),
|
|
description: "Profiles created or edited in your search area.",
|
|
needs: "location",
|
|
},
|
|
profilesSimilar: {
|
|
key: "profilesSimilar",
|
|
server: "partner",
|
|
label: (n) => plural(n, "Profile Like Yours", "Profiles Like Yours"),
|
|
description:
|
|
"Profiles whose description is close in meaning to yours, best match first.",
|
|
needs: "none",
|
|
},
|
|
plansSimilar: {
|
|
key: "plansSimilar",
|
|
server: "partner",
|
|
label: (n) =>
|
|
plural(
|
|
n,
|
|
"Project Matching Your Profile",
|
|
"Projects Matching Your Profile",
|
|
),
|
|
description:
|
|
"Projects whose description is close in meaning to your profile, best match first.",
|
|
needs: "none",
|
|
},
|
|
};
|
|
|
|
export function isNewActivityBucket(
|
|
value: unknown,
|
|
): value is NewActivityBucket {
|
|
return (
|
|
typeof value === "string" &&
|
|
(NEW_ACTIVITY_BUCKETS as readonly string[]).includes(value)
|
|
);
|
|
}
|
|
|
|
/** The rows of one bucket in a server's alertSearch data. */
|
|
export function bucketRows<S extends AlertSearchServer>(
|
|
data: AlertSearchServerData[S],
|
|
bucket: NewActivityBucket,
|
|
): unknown[] {
|
|
return ((data as unknown as Record<string, unknown[]>)[bucket] ||
|
|
[]) as unknown[];
|
|
}
|
|
|
|
export interface CursorPair {
|
|
endorser: AlertSearchCursor;
|
|
partner: AlertSearchCursor;
|
|
}
|
|
|
|
/**
|
|
* The span one load counted: from `from` (absent before any cursor was known)
|
|
* up to `to`, the cursor the server returned. `at` is when the visit began;
|
|
* `seenAt` is when `to` was recorded, and `fromSeenAt` when `from` was.
|
|
*/
|
|
export interface ActivityWindow<T> {
|
|
from?: T;
|
|
to: T;
|
|
at: string;
|
|
seenAt?: string;
|
|
fromSeenAt?: string;
|
|
}
|
|
|
|
export type NotificationChannel = "fcm" | "sms";
|
|
|
|
export interface NewActivityCursorState {
|
|
visit?: ActivityWindow<CursorPair>;
|
|
buckets?: Partial<
|
|
Record<NewActivityBucket, ActivityWindow<AlertSearchCursor>>
|
|
>;
|
|
/** The cursors last sent to the notification service, per channel. */
|
|
uploaded?: Partial<Record<NotificationChannel, CursorPair>>;
|
|
}
|
|
|
|
/** How long a visit lasts: loads inside it count from the same start. */
|
|
export const SAME_VISIT_MS = 30 * 60 * 1000;
|
|
|
|
/** The stored JSON, or an empty state when it is missing or unreadable. */
|
|
export function parseNewActivityCursorState(
|
|
text: string | undefined | null,
|
|
): NewActivityCursorState {
|
|
if (!text) return {};
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
return parsed && typeof parsed === "object" ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function serializeNewActivityCursorState(
|
|
state: NewActivityCursorState,
|
|
): string {
|
|
return JSON.stringify(state);
|
|
}
|
|
|
|
function isSameVisit<T>(
|
|
window: ActivityWindow<T> | undefined,
|
|
now: Date,
|
|
): boolean {
|
|
if (!window) return false;
|
|
const started = Date.parse(window.at);
|
|
return !Number.isNaN(started) && now.getTime() - started < SAME_VISIT_MS;
|
|
}
|
|
|
|
/** Where a load counts from: the same start inside a visit, else the last end. */
|
|
export function windowStart<T>(
|
|
window: ActivityWindow<T> | undefined,
|
|
now: Date,
|
|
): T | undefined {
|
|
return isSameVisit(window, now) ? window?.from : window?.to;
|
|
}
|
|
|
|
/** The window to store after a load that counted from `windowStart` up to `to`. */
|
|
export function nextWindow<T>(
|
|
window: ActivityWindow<T> | undefined,
|
|
to: T,
|
|
now: Date,
|
|
): ActivityWindow<T> {
|
|
if (window && isSameVisit(window, now)) {
|
|
return {
|
|
...(window.from !== undefined ? { from: window.from } : {}),
|
|
...(window.fromSeenAt ? { fromSeenAt: window.fromSeenAt } : {}),
|
|
to,
|
|
at: window.at,
|
|
seenAt: now.toISOString(),
|
|
};
|
|
}
|
|
return {
|
|
...(window?.to !== undefined
|
|
? { from: window.to, fromSeenAt: window.seenAt ?? window.at }
|
|
: {}),
|
|
to,
|
|
at: now.toISOString(),
|
|
seenAt: now.toISOString(),
|
|
};
|
|
}
|
|
|
|
function seenTime<T>(window: ActivityWindow<T> | undefined): number {
|
|
const time = Date.parse(window?.seenAt ?? window?.at ?? "");
|
|
return Number.isNaN(time) ? 0 : time;
|
|
}
|
|
|
|
/** When the cursor that {@link windowStart} returns was recorded. */
|
|
function startTime<T>(
|
|
window: ActivityWindow<T> | undefined,
|
|
now: Date,
|
|
): number {
|
|
if (!isSameVisit(window, now)) return seenTime(window);
|
|
const time = Date.parse(window?.fromSeenAt ?? window?.at ?? "");
|
|
return Number.isNaN(time) ? 0 : time;
|
|
}
|
|
|
|
/**
|
|
* When the cursor that {@link windowStart} returns was recorded, as stored;
|
|
* undefined when there is no start or its time was never recorded (a first
|
|
* visit from a notification link).
|
|
*/
|
|
export function windowStartSeenAt<T>(
|
|
window: ActivityWindow<T> | undefined,
|
|
now: Date,
|
|
): string | undefined {
|
|
if (!window) return undefined;
|
|
if (isSameVisit(window, now)) {
|
|
return window.from !== undefined ? window.fromSeenAt : undefined;
|
|
}
|
|
return window.seenAt ?? window.at;
|
|
}
|
|
|
|
/**
|
|
* The state after the person marks everything up to `end` read: a visit that
|
|
* starts and ends at `end`, and no list windows, so the summary, Home and
|
|
* every list count from `end`.
|
|
*/
|
|
export function markedReadState(
|
|
state: NewActivityCursorState,
|
|
end: CursorPair,
|
|
now: Date,
|
|
): NewActivityCursorState {
|
|
const at = now.toISOString();
|
|
return {
|
|
...(state.uploaded ? { uploaded: state.uploaded } : {}),
|
|
visit: { from: end, fromSeenAt: at, to: end, at, seenAt: at },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Where the summary counts a bucket from: the visit's start (or `fallback`,
|
|
* the notification link's cursors, when the visit has none), unless this
|
|
* bucket's list was opened after that start was recorded, in which case the
|
|
* end of that list. Opening a list therefore drops its count on the summary
|
|
* while the other buckets keep theirs for the rest of the visit.
|
|
*/
|
|
export function summaryStart(
|
|
state: NewActivityCursorState,
|
|
bucket: NewActivityBucket,
|
|
now: Date,
|
|
fallback?: CursorPair,
|
|
): AlertSearchCursor | undefined {
|
|
const server = NEW_ACTIVITY_BUCKET_INFO[bucket].server;
|
|
const visitStart =
|
|
windowStart(state.visit, now)?.[server] ?? fallback?.[server];
|
|
const bucketWindow = state.buckets?.[bucket];
|
|
if (!bucketWindow) return visitStart;
|
|
if (visitStart === undefined) return bucketWindow.to;
|
|
return seenTime(bucketWindow) > startTime(state.visit, now)
|
|
? bucketWindow.to
|
|
: visitStart;
|
|
}
|
|
|
|
/**
|
|
* Where the Home screen counts a bucket from: the end of whichever load the
|
|
* person saw last, the summary's or this bucket's own list. Unlike
|
|
* {@link windowStart} it never reaches back to the start of a visit, so a list
|
|
* just opened shows nothing new on Home.
|
|
*/
|
|
export function unseenStart(
|
|
state: NewActivityCursorState,
|
|
bucket: NewActivityBucket,
|
|
): AlertSearchCursor | undefined {
|
|
const visitTo = state.visit?.to[NEW_ACTIVITY_BUCKET_INFO[bucket].server];
|
|
const bucketWindow = state.buckets?.[bucket];
|
|
if (!bucketWindow) return visitTo;
|
|
if (visitTo === undefined) return bucketWindow.to;
|
|
return seenTime(bucketWindow) >= seenTime(state.visit)
|
|
? bucketWindow.to
|
|
: visitTo;
|
|
}
|
|
|
|
/**
|
|
* The `c` query value in notification links: the endorser and partner cursors
|
|
* joined by ".", which appears in neither server's cursor format.
|
|
*/
|
|
export function joinCursorPair(pair: CursorPair): string {
|
|
return pair.endorser + "." + pair.partner;
|
|
}
|
|
|
|
export function parseCursorParam(
|
|
value: string | undefined | null,
|
|
): CursorPair | undefined {
|
|
if (!value) return undefined;
|
|
const dot = value.indexOf(".");
|
|
if (dot <= 0 || dot === value.length - 1) return undefined;
|
|
return { endorser: value.slice(0, dot), partner: value.slice(dot + 1) };
|
|
}
|
|
|
|
export function sameCursorPair(
|
|
a: CursorPair | undefined,
|
|
b: CursorPair | undefined,
|
|
): boolean {
|
|
return (
|
|
a !== undefined &&
|
|
b !== undefined &&
|
|
a.endorser === b.endorser &&
|
|
a.partner === b.partner
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Request params for a load that starts at `cursor`. Without one, the request
|
|
* carries no cursor or ID, and the server answers with its most recent rows
|
|
* and its latest cursors.
|
|
*/
|
|
export function startParams(
|
|
cursor: AlertSearchCursor | undefined,
|
|
): AlertSearchRequestParams {
|
|
return cursor ? { afterCursor: cursor } : {};
|
|
}
|
|
|
|
/**
|
|
* The state with a visit ending at `latest`, as if the person had looked at
|
|
* everything up to it: nothing before it counts as new. The visit's start is
|
|
* long past, so the next load counts from `latest`.
|
|
*/
|
|
export function seededState(
|
|
state: NewActivityCursorState,
|
|
latest: CursorPair,
|
|
now: Date,
|
|
): NewActivityCursorState {
|
|
return {
|
|
...state,
|
|
visit: {
|
|
to: latest,
|
|
at: new Date(0).toISOString(),
|
|
seenAt: now.toISOString(),
|
|
},
|
|
};
|
|
}
|
|
|
|
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
|
|
/**
|
|
* The ULID immediately before `id` in sort order, so that `afterId` set to it
|
|
* includes `id` itself. Undefined for a value that is not a ULID or is all
|
|
* zeros.
|
|
*/
|
|
export function ulidJustBefore(id: string): string | undefined {
|
|
const upper = id.toUpperCase();
|
|
if (!/^[0-9A-HJKMNP-TV-Z]{26}$/.test(upper)) return undefined;
|
|
const chars = upper.split("");
|
|
for (let i = chars.length - 1; i >= 0; i--) {
|
|
const index = CROCKFORD.indexOf(chars[i]);
|
|
if (index > 0) {
|
|
chars[i] = CROCKFORD[index - 1];
|
|
return chars.join("");
|
|
}
|
|
chars[i] = CROCKFORD[CROCKFORD.length - 1];
|
|
}
|
|
return undefined;
|
|
}
|