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
141 lines
5.0 KiB
TypeScript
141 lines
5.0 KiB
TypeScript
import type { MessageBlock, ThreadMessage } from "@rakazo/contracts";
|
||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { i18n } from "./i18n.js";
|
||
import { intakeNoteText, localizedChoice, withIntakeNoteText } from "./onboarding-copy.js";
|
||
|
||
// The macros compile at build time; here they keep the English source.
|
||
vi.mock("@lingui/core/macro", () => {
|
||
const text = (parts: TemplateStringsArray, ...values: unknown[]) =>
|
||
parts.reduce(
|
||
(out, part, index) => out + part + (index < values.length ? values[index] : ""),
|
||
"",
|
||
);
|
||
return {
|
||
t: text,
|
||
msg: (parts: TemplateStringsArray, ...values: unknown[]) => {
|
||
const message = text(parts, ...values);
|
||
return { id: message, message };
|
||
},
|
||
};
|
||
});
|
||
|
||
type Choice = Extract<MessageBlock, { kind: "choice" }>;
|
||
type Note = Extract<MessageBlock, { kind: "intake_note" }>;
|
||
|
||
// Intl may separate the time from AM/PM with a narrow no-break space.
|
||
const plain = (text: string) => text.replace(/\s/g, " ");
|
||
|
||
beforeEach(() => {
|
||
i18n.load("en", {});
|
||
i18n.activate("en");
|
||
});
|
||
|
||
describe("localizedChoice", () => {
|
||
it("says a known card from its step and option ids, whatever the server wrote", () => {
|
||
const card: Choice = {
|
||
kind: "choice",
|
||
step: "rhythm",
|
||
question: "server question",
|
||
options: [
|
||
{ id: "morning", letter: "A", label: "server morning" },
|
||
{ id: "fortnightly", letter: "B", label: "Every other week" },
|
||
{ id: "none", letter: "", label: "server none" },
|
||
],
|
||
};
|
||
expect(localizedChoice(card)).toEqual({
|
||
...card,
|
||
question: "When should I check in with you?",
|
||
options: [
|
||
{ id: "morning", letter: "A", label: "Every morning" },
|
||
// An option the web does not know keeps the server text.
|
||
{ id: "fortnightly", letter: "B", label: "Every other week" },
|
||
{ id: "none", letter: "", label: "Not now" },
|
||
],
|
||
});
|
||
});
|
||
|
||
it("words the goal placeholder and keeps the server's quiet-hour ranges", () => {
|
||
const goal = localizedChoice({
|
||
kind: "choice",
|
||
step: "goal",
|
||
question: "?",
|
||
textInput: { placeholder: "server placeholder" },
|
||
options: [{ id: "skip", letter: "", label: "server skip" }],
|
||
});
|
||
expect(goal.textInput).toEqual({ placeholder: "Your goal" });
|
||
expect(goal.options[0]?.label).toBe("Skip");
|
||
const quiet = localizedChoice({
|
||
kind: "choice",
|
||
step: "quiet_hours",
|
||
question: "?",
|
||
options: [{ id: "22-07", letter: "A", label: "22:00 – 7:00" }],
|
||
});
|
||
expect(quiet.options[0]?.label).toBe("22:00 – 7:00");
|
||
});
|
||
|
||
it("recognizes another bot's focus card and leaves other cards alone", () => {
|
||
const focus: Choice = {
|
||
kind: "choice",
|
||
question: "server question",
|
||
options: [
|
||
{ id: "day", letter: "A", label: "x" },
|
||
{ id: "crew", letter: "D", label: "y" },
|
||
],
|
||
};
|
||
expect(localizedChoice(focus).question).toBe("What do you want me on first?");
|
||
expect(localizedChoice(focus).options.map((option) => option.label)).toEqual([
|
||
"Day-to-day work",
|
||
"Specialist crew",
|
||
]);
|
||
const other: Choice = {
|
||
kind: "choice",
|
||
question: "Pick one",
|
||
options: [{ id: "a", letter: "A", label: "Apples" }],
|
||
};
|
||
expect(localizedChoice(other)).toBe(other);
|
||
});
|
||
});
|
||
|
||
describe("intakeNoteText", () => {
|
||
const now = new Date(2026, 9, 7, 10, 0);
|
||
const note = (nextCheckInAt: Date | null): Note => ({
|
||
kind: "intake_note",
|
||
note: "done",
|
||
nextCheckInAt: nextCheckInAt?.toISOString() ?? null,
|
||
text: "server text",
|
||
});
|
||
|
||
it("names the first check-in relative to today, in the user's clock", () => {
|
||
expect(plain(intakeNoteText(note(new Date(2026, 9, 7, 18, 0)), now))).toBe(
|
||
"All set. I’ll check in today at 6:00 PM.",
|
||
);
|
||
expect(plain(intakeNoteText(note(new Date(2026, 9, 8, 8, 0)), now))).toBe(
|
||
"All set. I’ll check in tomorrow at 8:00 AM.",
|
||
);
|
||
expect(plain(intakeNoteText(note(new Date(2026, 9, 11, 18, 0)), now))).toBe(
|
||
"All set. I’ll check in on Sunday at 6:00 PM.",
|
||
);
|
||
});
|
||
|
||
it("has a line for no check-in and falls back to the server text when unreadable", () => {
|
||
expect(intakeNoteText(note(null), now)).toBe("All set. I’m here whenever you need me.");
|
||
expect(intakeNoteText({ ...note(null), nextCheckInAt: "soon" }, now)).toBe("server text");
|
||
});
|
||
});
|
||
|
||
describe("withIntakeNoteText", () => {
|
||
const message = (blocks: MessageBlock[]) =>
|
||
({ id: "m", role: "bot", blocks, createdAt: "2026-10-07T08:00:00.000Z" }) as ThreadMessage;
|
||
|
||
it("turns notes into text and leaves other messages untouched", () => {
|
||
const plainMessage = message([{ kind: "text", text: "hi" }]);
|
||
expect(withIntakeNoteText(plainMessage)).toBe(plainMessage);
|
||
const withNote = message([
|
||
{ kind: "intake_note", note: "done", nextCheckInAt: null, text: "server text" },
|
||
]);
|
||
expect(withIntakeNoteText(withNote).blocks).toEqual([
|
||
{ kind: "text", text: "All set. I’m here whenever you need me." },
|
||
]);
|
||
});
|
||
});
|