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

299 lines
8.7 KiB
TypeScript

/**
* Onboarding by interests, on every platform.
*
* The state is one JSON settings column, `onboardingState`, per account:
* - `stage` moves `interests` (prompt pending) → `matches` (interests kept,
* results not shown yet) → `save` (results seen; the nudge to save a profile
* is on) → `done`. "Not now" on the prompt goes straight to
* `done`.
* - `interests`, `lat`, `lon` are the draft of a public profile. They are kept
* past `done` and cleared only by a profile save or "Forget"; `lat` and `lon`
* are always rounded ({@link roundCoordinate}).
* - `welcomeTour` makes Home, Discover, and Projects open the three welcome
* pages, which open only as a tour started from Help.
*
* The nudge asks for a saved profile everywhere, and for a daily alert only
* where the app offers alerts ({@link offersAlerts}).
*/
import type { BoundingBox } from "@/db/tables/settings";
export type OnboardingStage = "interests" | "matches" | "save" | "done";
export interface OnboardingState {
stage: OnboardingStage;
interests?: string;
lat?: number;
lon?: number;
matchesShownAt?: string | null;
bannerShownAt?: string | null;
declinedAt?: string | null;
welcomeTour?: boolean;
}
/** The four corners the partner server's location filters take. */
export interface LocBox {
minLocLat: number;
minLocLon: number;
maxLocLat: number;
maxLocLon: number;
}
const STAGES: ReadonlyArray<OnboardingStage> = [
"interests",
"matches",
"save",
"done",
];
/** Radius of the area searched around the person's point. */
export const SEARCH_RADIUS_KM = 50;
const KM_PER_DEGREE_LAT = 111;
export function parseOnboardingState(
text: string | undefined | null,
): OnboardingState {
if (!text) return { stage: "interests" };
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object") return { stage: "interests" };
const stage = STAGES.includes(parsed.stage) ? parsed.stage : "interests";
return { ...parsed, stage };
} catch {
return { stage: "interests" };
}
}
export function serializeOnboardingState(state: OnboardingState): string {
return JSON.stringify(state);
}
/**
* The settings to write for a state: the column, plus `finishedOnboarding`
* once the stage is `done` so everything that reads that flag keeps working.
*/
export function onboardingSettingsUpdate(state: OnboardingState): {
onboardingState: string;
finishedOnboarding?: boolean;
} {
return {
onboardingState: serializeOnboardingState(state),
...(state.stage === "done" ? { finishedOnboarding: true } : {}),
};
}
/**
* True on the Android and iOS apps, the only ones with New Activity alerts,
* so the only ones where the nudge offers one.
*/
export function offersAlerts(capabilities: {
isNativeApp?: boolean;
isMobile?: boolean;
}): boolean {
return !!capabilities?.isNativeApp && !!capabilities?.isMobile;
}
/** Two decimals, about 1 km: the precision of everything stored or saved. */
export function roundCoordinate(value: number): number {
return Math.round(value * 100) / 100;
}
export function hasPoint(
state: Pick<OnboardingState, "lat" | "lon">,
): state is { lat: number; lon: number } {
return typeof state.lat === "number" && typeof state.lon === "number";
}
/**
* "Find matches": keep the text and the rounded point, before any network
* call. From `interests` or `done` the flow (re)starts at `matches`; from
* `matches` or `save` the stage stays, so a re-entry from Help doesn't undo
* a nudge already on.
*/
export function withInterests(
state: OnboardingState,
interests: string,
point: { lat: number; lon: number } | null,
): OnboardingState {
const stage =
state.stage === "interests" || state.stage === "done"
? "matches"
: state.stage;
const next: OnboardingState = {
...state,
stage,
interests: interests.trim(),
};
if (point) {
next.lat = roundCoordinate(point.lat);
next.lon = roundCoordinate(point.lon);
} else {
delete next.lat;
delete next.lon;
}
return next;
}
/** The first successful results render turns the nudge on. */
export function withMatchesShown(
state: OnboardingState,
now: Date,
): OnboardingState {
if (state.stage !== "matches") return state;
return {
...state,
stage: "save",
matchesShownAt: state.matchesShownAt || now.toISOString(),
};
}
/**
* "Not now" on the prompt. Text typed before declining is kept as a profile
* draft; with nothing typed, any earlier draft stays as it was.
*/
export function withDeclined(
state: OnboardingState,
now: Date,
typed?: { interests: string; point: { lat: number; lon: number } | null },
): OnboardingState {
const next: OnboardingState = {
...state,
stage: "done",
declinedAt: now.toISOString(),
};
if (typed && typed.interests.trim()) {
return {
...withInterests(next, typed.interests, typed.point),
stage: "done",
};
}
return next;
}
/** "Done", "No thanks", or both card steps finished. The draft stays. */
export function withDone(state: OnboardingState): OnboardingState {
return { ...state, stage: "done" };
}
/** A saved profile or "Forget": the phone no longer holds a draft. */
export function withoutInterests(state: OnboardingState): OnboardingState {
const next = { ...state };
delete next.interests;
delete next.lat;
delete next.lon;
return next;
}
export function withBannerShown(
state: OnboardingState,
now: Date,
): OnboardingState {
return { ...state, bannerShownAt: state.bannerShownAt || now.toISOString() };
}
export function withWelcomeTour(
state: OnboardingState,
welcomeTour: boolean,
): OnboardingState {
return { ...state, welcomeTour };
}
/** The dot, banner, and card: results seen, and registered to act on them. */
export function isNudgeOn(
state: OnboardingState,
isRegistered: boolean,
): boolean {
return state.stage === "save" && isRegistered;
}
export function shouldAnimateBanner(
state: OnboardingState,
isRegistered: boolean,
): boolean {
return isNudgeOn(state, isRegistered) && !state.bannerShownAt;
}
/** Whether a page's welcome dialog opens: only during a tour started from Help. */
export function shouldOpenWelcome(settings: {
onboardingState?: string;
}): boolean {
return !!parseOnboardingState(settings.onboardingState).welcomeTour;
}
/**
* A box of about `radiusKm` around a point, the form the partner server
* filters on. Longitude is widened by 1/cos(lat) so the box stays square on
* the ground; near the poles it is capped at the whole range.
*/
export function boundingBoxAround(
lat: number,
lon: number,
radiusKm: number = SEARCH_RADIUS_KM,
): LocBox {
const dLat = radiusKm / KM_PER_DEGREE_LAT;
const cosLat = Math.cos((lat * Math.PI) / 180);
const dLon = cosLat > 0.01 ? Math.min(dLat / cosLat, 180) : 180;
return {
minLocLat: Math.max(lat - dLat, -90),
maxLocLat: Math.min(lat + dLat, 90),
minLocLon: Math.max(lon - dLon, -180),
maxLocLon: Math.min(lon + dLon, 180),
};
}
/** The same box as a search area (`settings.searchBoxes`). */
export function locBoxToBoundingBox(box: LocBox): BoundingBox {
return {
eastLong: box.maxLocLon,
maxLat: box.maxLocLat,
minLat: box.minLocLat,
westLong: box.minLocLon,
};
}
/** The /similar score at and above which a match is shown by default. */
export const STRONG_SIMILARITY = 0.85;
/**
* A short word for a /similar score, or "" for a weak match, which shows no
* label. The cuts follow the server's embedding spec: 0.78 is the search
* minimum and 0.85 the alert minimum.
*/
export function similarityLabel(similarity: number): "strong" | "weak" | "" {
if (similarity >= STRONG_SIMILARITY) return "strong";
if (similarity >= 0.81) return "weak";
return "";
}
/**
* What the matches view says under "No strong matches yet":
* - `register`: not registered, so no saved profile and no alerts
* - `alertsOn`: a saved profile and a daily alert, so strong matches arrive
* - `setUp`: registered, missing the profile save or (on the phone apps) an
* alert; the Profile page's follow-up card does both
* - `profileOnly`: a saved profile where the app has no alerts (the web)
*/
export type NoStrongFollowUp =
| "register"
| "alertsOn"
| "setUp"
| "profileOnly";
export function noStrongFollowUp(input: {
isRegistered: boolean;
profileSaved: boolean;
alertsOn: boolean;
offersAlerts: boolean;
}): NoStrongFollowUp {
if (!input.isRegistered) return "register";
if (!input.profileSaved) return "setUp";
if (input.alertsOn) return "alertsOn";
return input.offersAlerts ? "setUp" : "profileOnly";
}
/** Whether a match is strong enough to show without expanding. */
export function isStrongMatch(similarity: number): boolean {
return similarity >= STRONG_SIMILARITY;
}