Files
crowd-funder-for-time-pwa/src/libs/newActivity.ts
T

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;
}