evOo-local/apps/web/src/lib/onboarding-copy.test.ts
Claude d3689b88fc
feat(onboarding): show the onboarding cards and check-in names in the user's language
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
2026-10-03 14:51:59 +00:00

141 lines
5.0 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 { 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." },
]);
});
});