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 { createGoal, type Prisma, type PrismaClient, 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; 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; } export interface FocusOption { id: string; letter: string; label: string; summary: string; apps: string[]; /** Stored as a `goals` fact, so it stays a plain English statement. */ fact: string; } export const FOCUS_OPTIONS: FocusOption[] = [ { id: "day", letter: "A", label: "Day-to-day work", summary: "Slack, calendar, and email", apps: ["slack", "gmail", "googlecalendar"], fact: "Wants help with day-to-day work first", }, { id: "inbox", letter: "B", label: "Inbox & email", summary: "email and calendar", apps: ["gmail", "googlecalendar", "slack"], fact: "Wants help with inbox and email first", }, { id: "research", letter: "C", label: "Research & writing", summary: "the web, notes, and docs", apps: ["hackernews", "notion", "googledocs"], fact: "Wants help with research and writing first", }, { id: "crew", letter: "D", label: "Specialist crew", summary: "a coordinated team of focused Oos", apps: ["slack", "gmail", "googlecalendar"], fact: "Wants a coordinated crew of specialist bots", }, ]; 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 = { 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 = { "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; await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } }); await updateUserProfile(prisma, userId, { addFacts: [{ category: "goals", value: option.fact, 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; } /** One line that says what happens next: the first check-in, when there is one. */ export function closingLine(rhythm: CoachRhythm, context: IntakeContext): string { const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []); const next = nextCronDateAcross(crons, context.now, context.timeZone); 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}.`; }