The onboarding cards, the closing line and the coach routine names were English server text for every user. The server keeps the state and an English fallback; the web now words them through Lingui, so there is one translation source. - Choice cards: the web says the question, the option labels and the placeholder from the step and option ids, and keeps the server text for anything it does not know (quiet-hour ranges are already written in the user's locale). Another bot's focus card is recognized by its option ids. Mobile and agent history keep the server text. - The "Got it. … are a good place to start." line is gone: the app cards speak for themselves, and with no cards nothing is said. - The closing line becomes an intake_note block (note "done", nextCheckInAt, English text). The web words it with the first check-in in the user's clock; mobile blockText and blocksToAgentHistoryText read its text through their generic fallback. - Routines carry coachKey. The routine list and editor show a check-in under its label in the UI language until the user renames it; saving an untouched label keeps the stored name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
108 lines
4.0 KiB
TypeScript
108 lines
4.0 KiB
TypeScript
import type { MessageDescriptor } from "@lingui/core";
|
||
import { msg, t } from "@lingui/core/macro";
|
||
import type { MessageBlock, OnboardingStep, ThreadMessage } from "@rakazo/contracts";
|
||
import { i18n } from "./i18n";
|
||
|
||
/**
|
||
* The onboarding cards in the user's language. The server stores each card with its step and
|
||
* option ids plus English text; the web says what it knows from the ids and keeps the server
|
||
* text for anything new (mobile and agent history always use the server text).
|
||
*/
|
||
|
||
type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
|
||
type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
|
||
|
||
const QUESTIONS: Record<OnboardingStep, MessageDescriptor> = {
|
||
focus: msg`What do you want me on first?`,
|
||
goal: msg`What’s one thing you want to get done in the next few weeks?`,
|
||
rhythm: msg`When should I check in with you?`,
|
||
quiet_hours: msg`When should I stay quiet?`,
|
||
notifications: msg`Can I send you notifications?`,
|
||
};
|
||
|
||
const SKIP = msg`Skip`;
|
||
const NOT_NOW = msg`Not now`;
|
||
|
||
// Quiet-hour ranges are left out: the server already writes them in the user's locale.
|
||
const OPTIONS: Record<OnboardingStep, Record<string, MessageDescriptor>> = {
|
||
focus: {
|
||
day: msg`Day-to-day work`,
|
||
inbox: msg`Inbox & email`,
|
||
research: msg`Research & writing`,
|
||
crew: msg`Specialist crew`,
|
||
skip: SKIP,
|
||
},
|
||
goal: { skip: SKIP },
|
||
rhythm: {
|
||
morning: msg`Every morning`,
|
||
morning_evening: msg`Morning and evening`,
|
||
weekly: msg`Once a week`,
|
||
none: NOT_NOW,
|
||
},
|
||
quiet_hours: { skip: SKIP },
|
||
notifications: { on: msg`Turn on`, later: NOT_NOW },
|
||
};
|
||
|
||
const PLACEHOLDERS: Partial<Record<OnboardingStep, MessageDescriptor>> = {
|
||
goal: msg`Your goal`,
|
||
};
|
||
|
||
/** Another bot's focus card carries no step; its option ids give it away. */
|
||
function cardStep(card: ChoiceBlock): OnboardingStep | undefined {
|
||
if (card.step) return card.step;
|
||
const focus = OPTIONS.focus;
|
||
return card.options.length > 0 && card.options.every((option) => focus[option.id])
|
||
? "focus"
|
||
: undefined;
|
||
}
|
||
|
||
/** The card's question, options and placeholder in the user's language. */
|
||
export function localizedChoice(card: ChoiceBlock): ChoiceBlock {
|
||
const step = cardStep(card);
|
||
if (!step) return card;
|
||
const options = OPTIONS[step];
|
||
const placeholder = PLACEHOLDERS[step];
|
||
return {
|
||
...card,
|
||
question: i18n._(QUESTIONS[step]),
|
||
options: card.options.map((option) => {
|
||
const label = options[option.id];
|
||
return label ? { ...option, label: i18n._(label) } : option;
|
||
}),
|
||
...(card.textInput && placeholder ? { textInput: { placeholder: i18n._(placeholder) } } : {}),
|
||
};
|
||
}
|
||
|
||
function sameDay(left: Date, right: Date): boolean {
|
||
return left.toDateString() === right.toDateString();
|
||
}
|
||
|
||
/** The closing note in the user's language, with the first check-in in their time zone. */
|
||
export function intakeNoteText(note: IntakeNote, now = new Date()): string {
|
||
if (note.note !== "done") return note.text;
|
||
if (!note.nextCheckInAt) return t`All set. I’m here whenever you need me.`;
|
||
const at = new Date(note.nextCheckInAt);
|
||
if (!Number.isFinite(at.getTime())) return note.text;
|
||
const time = new Intl.DateTimeFormat(i18n.locale || "en", {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
}).format(at);
|
||
const tomorrow = new Date(now);
|
||
tomorrow.setDate(now.getDate() + 1);
|
||
if (sameDay(at, now)) return t`All set. I’ll check in today at ${time}.`;
|
||
if (sameDay(at, tomorrow)) return t`All set. I’ll check in tomorrow at ${time}.`;
|
||
const weekday = new Intl.DateTimeFormat(i18n.locale || "en", { weekday: "long" }).format(at);
|
||
return t`All set. I’ll check in on ${weekday} at ${time}.`;
|
||
}
|
||
|
||
/** The message with onboarding notes as plain text, so bubbles, copy and speech read the same. */
|
||
export function withIntakeNoteText(message: ThreadMessage): ThreadMessage {
|
||
if (!message.blocks.some((block) => block.kind === "intake_note")) return message;
|
||
return {
|
||
...message,
|
||
blocks: message.blocks.map((block) =>
|
||
block.kind === "intake_note" ? { kind: "text", text: intakeNoteText(block) } : block,
|
||
),
|
||
};
|
||
}
|