With the coach and notification modules merged, the onboarding answers now take effect: - the goal card creates a goal (source onboarding) through createGoal; - the rhythm card sets the main bot's check-in routines through setCoachRhythm, and the closing line names the first check-in from the routines that were saved; - the quiet-hours card updates the notification settings; - the choice card tells the server whether this browser can still turn push on, so the notifications card is offered only there, and "Turn on" subscribes before answering. The thread lock becomes NO KEY UPDATE so a rhythm routine, which references the thread, can be written while the answer holds the lock. The goal answer is capped at the coach title length. A Postgres suite walks the questions end to end: answers, skips, a dismissed card, a stale and a doubled answer, another bot's focus card, and another user's access. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
294 lines
9.5 KiB
TypeScript
294 lines
9.5 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 {
|
||
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<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;
|
||
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<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;
|
||
}
|
||
|
||
/** 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}.`;
|
||
}
|