evOo-local/apps/api/src/onboarding-intake.ts
Claude 27f02c05af
style: use top-level type imports in the coach changes
AGENTS.md asks for top-level `import type` instead of inline `type`
specifiers; this applies it to the import statements the coach work added
or changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:53:03 +00:00

298 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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[];
/** 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",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants help with day-to-day work first",
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
apps: ["gmail", "googlecalendar", "slack"],
fact: "Wants help with inbox and email first",
},
{
id: "research",
letter: "C",
label: "Research & writing",
apps: ["hackernews", "notion", "googledocs"],
fact: "Wants help with research and writing first",
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
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<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;
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;
}
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}.`;
}