322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||
import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters";
|
||
import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts";
|
||
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||
import { knownTimeZone, nextCronDateAcross } from "@rakazo/core";
|
||
import type { Prisma, PrismaClient } from "@rakazo/db";
|
||
import { createGoal, updateNotificationSettings, updateUserProfile } from "@rakazo/db";
|
||
|
||
/**
|
||
* The questions the main bot asks a new user in the main conversation, one card at a time.
|
||
* Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order
|
||
* the user sees. Everything here is deterministic, so no model tokens are spent.
|
||
*/
|
||
|
||
export type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
|
||
type ChoiceOption = ChoiceBlock["options"][number];
|
||
|
||
/** A validated answer: an option of the card, or the user's own words. */
|
||
export type IntakeAnswer =
|
||
| { option: ChoiceOption; text?: undefined }
|
||
| { option?: undefined; text: string };
|
||
|
||
/** What a card may adapt to: times are written in the user's locale. */
|
||
export interface IntakeContext {
|
||
locale: string;
|
||
timeZone: string;
|
||
now: Date;
|
||
}
|
||
|
||
export interface IntakeEffects {
|
||
prisma: PrismaClient;
|
||
jobs: JobPublisher;
|
||
tx: Prisma.TransactionClient;
|
||
userId: string;
|
||
}
|
||
|
||
interface IntakeStep {
|
||
id: OnboardingStep;
|
||
/** Asked only when this holds; otherwise onboarding moves past the step. */
|
||
asks?: (device: { canEnablePush: boolean }) => boolean;
|
||
card: (context: IntakeContext) => ChoiceBlock;
|
||
/** Records a validated answer. Runs while the main conversation is locked. */
|
||
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
|
||
}
|
||
|
||
export interface FocusOption {
|
||
id: string;
|
||
letter: string;
|
||
label: string;
|
||
apps: string[];
|
||
}
|
||
|
||
const FOCUS_FACTS: Record<string, Record<FocusOption["id"], string>> = {
|
||
en: {
|
||
day: "Wants help with day-to-day work first",
|
||
inbox: "Wants help with inbox and email first",
|
||
research: "Wants help with research and writing first",
|
||
crew: "Wants a coordinated crew of specialist bots",
|
||
},
|
||
de: {
|
||
day: "Möchte zuerst bei der täglichen Arbeit unterstützt werden",
|
||
inbox: "Möchte zuerst bei Posteingang und E-Mail unterstützt werden",
|
||
research: "Möchte zuerst bei Recherche und Schreiben unterstützt werden",
|
||
crew: "Möchte eine koordinierte Crew aus Spezialisten-Bots",
|
||
},
|
||
};
|
||
|
||
/** Profile fact text for a focus answer, in the user's locale (defaults to English). */
|
||
export function focusFactValue(focusId: string, locale: string): string {
|
||
const id = focusId as FocusOption["id"];
|
||
const key = locale.toLowerCase().startsWith("de") ? "de" : "en";
|
||
const table = FOCUS_FACTS[key];
|
||
return table[id] ?? FOCUS_FACTS.en[id] ?? focusId;
|
||
}
|
||
|
||
export const FOCUS_OPTIONS: FocusOption[] = [
|
||
{
|
||
id: "day",
|
||
letter: "A",
|
||
label: "Day-to-day work",
|
||
apps: ["slack", "gmail", "googlecalendar"],
|
||
},
|
||
{
|
||
id: "inbox",
|
||
letter: "B",
|
||
label: "Inbox & email",
|
||
apps: ["gmail", "googlecalendar", "slack"],
|
||
},
|
||
{
|
||
id: "research",
|
||
letter: "C",
|
||
label: "Research & writing",
|
||
apps: ["hackernews", "notion", "googledocs"],
|
||
},
|
||
{
|
||
id: "crew",
|
||
letter: "D",
|
||
label: "Specialist crew",
|
||
apps: ["slack", "gmail", "googlecalendar"],
|
||
},
|
||
];
|
||
|
||
const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" };
|
||
|
||
/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */
|
||
export function focusCard(step?: "focus"): ChoiceBlock {
|
||
return {
|
||
kind: "choice",
|
||
question: "What do you want me on first?",
|
||
options: [
|
||
...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
|
||
...(step ? [SKIP] : []),
|
||
],
|
||
...(step ? { step } : {}),
|
||
};
|
||
}
|
||
|
||
const RHYTHMS: Record<string, CoachRhythm> = {
|
||
morning: { morning: "08:00", evening: null, weekly: null },
|
||
morning_evening: { morning: "08:00", evening: "18:00", weekly: null },
|
||
weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } },
|
||
none: { morning: null, evening: null, weekly: null },
|
||
};
|
||
|
||
const QUIET_HOURS: Record<string, QuietHours> = {
|
||
"22-07": { start: "22:00", end: "07:00" },
|
||
"23-08": { start: "23:00", end: "08:00" },
|
||
};
|
||
|
||
export const INTAKE_STEPS: IntakeStep[] = [
|
||
{
|
||
id: "focus",
|
||
card: () => focusCard("focus"),
|
||
save: async ({ prisma, tx, userId }, answer) => {
|
||
const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id);
|
||
if (!option) return;
|
||
const profile = await tx.userProfile.findUnique({
|
||
where: { userId },
|
||
select: { locale: true, timeZone: true },
|
||
});
|
||
const locale = intakeContext(profile, new Date()).locale;
|
||
await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } });
|
||
await updateUserProfile(prisma, userId, {
|
||
addFacts: [
|
||
{ category: "goals", value: focusFactValue(option.id, locale), source: "onboarding" },
|
||
],
|
||
});
|
||
},
|
||
},
|
||
{
|
||
id: "goal",
|
||
card: () => ({
|
||
kind: "choice",
|
||
step: "goal",
|
||
question: "What’s one thing you want to get done in the next few weeks?",
|
||
textInput: { placeholder: "Your goal" },
|
||
options: [SKIP],
|
||
}),
|
||
save: async ({ prisma, userId }, answer) => {
|
||
if (answer.text)
|
||
await createGoal(prisma, userId, { title: answer.text, source: "onboarding" });
|
||
},
|
||
},
|
||
{
|
||
id: "rhythm",
|
||
card: () => ({
|
||
kind: "choice",
|
||
step: "rhythm",
|
||
question: "When should I check in with you?",
|
||
options: [
|
||
{ id: "morning", letter: "A", label: "Every morning" },
|
||
{ id: "morning_evening", letter: "B", label: "Morning and evening" },
|
||
{ id: "weekly", letter: "C", label: "Once a week" },
|
||
{ id: "none", letter: "", label: "Not now" },
|
||
],
|
||
}),
|
||
save: async ({ prisma, jobs, userId }, answer) => {
|
||
const rhythm = answer.option && RHYTHMS[answer.option.id];
|
||
if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, rhythm });
|
||
},
|
||
},
|
||
{
|
||
id: "quiet_hours",
|
||
card: (context) => ({
|
||
kind: "choice",
|
||
step: "quiet_hours",
|
||
question: "When should I stay quiet?",
|
||
options: [
|
||
...Object.entries(QUIET_HOURS).map(([id, range], index) => ({
|
||
id,
|
||
letter: String.fromCharCode(65 + index),
|
||
label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`,
|
||
})),
|
||
SKIP,
|
||
],
|
||
}),
|
||
save: async ({ prisma, userId }, answer) => {
|
||
const quietHours = answer.option && QUIET_HOURS[answer.option.id];
|
||
if (quietHours) await updateNotificationSettings(prisma, userId, { quietHours });
|
||
},
|
||
},
|
||
{
|
||
// The browser subscribes when the user taps "Turn on"; the answer only moves on.
|
||
id: "notifications",
|
||
asks: ({ canEnablePush }) => canEnablePush,
|
||
card: () => ({
|
||
kind: "choice",
|
||
step: "notifications",
|
||
question: "Can I send you notifications?",
|
||
options: [
|
||
{ id: "on", letter: "A", label: "Turn on" },
|
||
{ id: "later", letter: "", label: "Not now" },
|
||
],
|
||
}),
|
||
save: async () => {},
|
||
},
|
||
];
|
||
|
||
export function intakeStep(id: OnboardingStep): IntakeStep {
|
||
const step = INTAKE_STEPS.find((entry) => entry.id === id);
|
||
if (!step) throw new Error(`Unknown onboarding step: ${id}`);
|
||
return step;
|
||
}
|
||
|
||
/** The step after `id` that applies to this device, or null when onboarding is done. */
|
||
export function nextIntakeStep(
|
||
id: OnboardingStep,
|
||
device: { canEnablePush: boolean },
|
||
): IntakeStep | null {
|
||
const index = INTAKE_STEPS.findIndex((entry) => entry.id === id);
|
||
return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null;
|
||
}
|
||
|
||
/** Checks an answer against the card it answers; null when the card does not offer it. */
|
||
export function readIntakeAnswer(
|
||
card: ChoiceBlock,
|
||
optionId: string,
|
||
text: string | undefined,
|
||
): IntakeAnswer | null {
|
||
if (optionId === CHOICE_TEXT_ANSWER_ID) {
|
||
const words = text?.trim();
|
||
return card.textInput && words ? { text: words } : null;
|
||
}
|
||
const option = card.options.find((entry) => entry.id === optionId);
|
||
return option ? { option } : null;
|
||
}
|
||
|
||
/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */
|
||
export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined {
|
||
return card.options.find((option) => !option.letter);
|
||
}
|
||
|
||
/** Card times and the closing line follow the user's profile, falling back to English and UTC. */
|
||
export function intakeContext(
|
||
profile: { locale: string | null; timeZone: string | null } | null,
|
||
now: Date,
|
||
): IntakeContext {
|
||
let locale = "en";
|
||
try {
|
||
if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en";
|
||
} catch {
|
||
// Not a language tag; keep English.
|
||
}
|
||
return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now };
|
||
}
|
||
|
||
function formatClock(time: string, locale: string): string {
|
||
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
||
return new Intl.DateTimeFormat(locale, {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
timeZone: "UTC",
|
||
}).format(new Date(Date.UTC(2000, 0, 1, hour, minute)));
|
||
}
|
||
|
||
function localDay(date: Date, timeZone: string): number {
|
||
const [year, month, day] = new Intl.DateTimeFormat("en-CA", {
|
||
timeZone,
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
})
|
||
.format(date)
|
||
.split("-")
|
||
.map(Number);
|
||
return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000;
|
||
}
|
||
|
||
export type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
|
||
|
||
/**
|
||
* The closing note: what happens next, i.e. the first check-in when there is one. The web says
|
||
* it in the user's language from `nextCheckInAt`; `text` is the English line for everything else.
|
||
*/
|
||
export function closingNote(rhythm: CoachRhythm, context: IntakeContext): IntakeNote {
|
||
const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []);
|
||
const next = nextCronDateAcross(crons, context.now, context.timeZone);
|
||
return {
|
||
kind: "intake_note",
|
||
note: "done",
|
||
nextCheckInAt: next?.toISOString() ?? null,
|
||
text: closingText(next, context),
|
||
};
|
||
}
|
||
|
||
function closingText(next: Date | null, context: IntakeContext): string {
|
||
if (!next) return "All set. I’m here whenever you need me.";
|
||
const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone);
|
||
const day =
|
||
days === 0
|
||
? "today"
|
||
: days === 1
|
||
? "tomorrow"
|
||
: `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`;
|
||
const time = new Intl.DateTimeFormat(context.locale, {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
timeZone: context.timeZone,
|
||
}).format(next);
|
||
return `All set. I’ll check in ${day} at ${time}.`;
|
||
}
|