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
109 lines
4.6 KiB
TypeScript
109 lines
4.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
closingNote,
|
||
focusCard,
|
||
intakeContext,
|
||
intakeStep,
|
||
nextIntakeStep,
|
||
readIntakeAnswer,
|
||
secondaryOption,
|
||
} from "./onboarding-intake.js";
|
||
|
||
// Intl may separate the time from AM/PM with a narrow no-break space.
|
||
const plain = (text: string) => text.replace(/\s/g, " ");
|
||
const off = { morning: null, evening: null, weekly: null };
|
||
|
||
describe("onboarding steps", () => {
|
||
it("asks in order and offers notifications only to a device that can still turn push on", () => {
|
||
expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal");
|
||
expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm");
|
||
expect(nextIntakeStep("rhythm", { canEnablePush: false })?.id).toBe("quiet_hours");
|
||
expect(nextIntakeStep("quiet_hours", { canEnablePush: true })?.id).toBe("notifications");
|
||
expect(nextIntakeStep("quiet_hours", { canEnablePush: false })).toBeNull();
|
||
expect(nextIntakeStep("notifications", { canEnablePush: true })).toBeNull();
|
||
});
|
||
|
||
it("gives every onboarding card a quiet way out, and another bot's focus card none", () => {
|
||
const context = intakeContext(null, new Date());
|
||
for (const id of ["focus", "goal", "rhythm", "quiet_hours", "notifications"] as const) {
|
||
const card = intakeStep(id).card(context);
|
||
expect(card.step).toBe(id);
|
||
expect(secondaryOption(card)).toBeDefined();
|
||
}
|
||
expect(focusCard().step).toBeUndefined();
|
||
expect(secondaryOption(focusCard())).toBeUndefined();
|
||
});
|
||
|
||
it("writes quiet hours in the user's locale", () => {
|
||
const labels = (locale: string | null) =>
|
||
intakeStep("quiet_hours")
|
||
.card(intakeContext({ locale, timeZone: null }, new Date()))
|
||
.options.map((option) => plain(option.label));
|
||
expect(labels("de-AT")).toEqual(["22:00 – 7:00", "23:00 – 8:00", "Skip"]);
|
||
expect(labels("en-US")).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]);
|
||
expect(labels("not a tag")).toEqual(labels("en"));
|
||
});
|
||
});
|
||
|
||
describe("readIntakeAnswer", () => {
|
||
const goal = intakeStep("goal").card(intakeContext(null, new Date()));
|
||
const rhythm = intakeStep("rhythm").card(intakeContext(null, new Date()));
|
||
|
||
it("accepts the card's own options and words only where the card takes them", () => {
|
||
expect(readIntakeAnswer(rhythm, "weekly", undefined)).toEqual({
|
||
option: { id: "weekly", letter: "C", label: "Once a week" },
|
||
});
|
||
expect(readIntakeAnswer(rhythm, "skip", undefined)).toBeNull();
|
||
expect(readIntakeAnswer(rhythm, "_text", "Mornings")).toBeNull();
|
||
expect(readIntakeAnswer(goal, "_text", " Run a 10K ")).toEqual({ text: "Run a 10K" });
|
||
expect(readIntakeAnswer(goal, "_text", " ")).toBeNull();
|
||
expect(readIntakeAnswer(goal, "skip", undefined)?.option?.label).toBe("Skip");
|
||
});
|
||
});
|
||
|
||
describe("closingNote", () => {
|
||
const vienna = (iso: string, locale = "de-AT") =>
|
||
intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso));
|
||
const note = (rhythm: Parameters<typeof closingNote>[0], context: ReturnType<typeof vienna>) => {
|
||
const { nextCheckInAt, text } = closingNote(rhythm, context);
|
||
return { nextCheckInAt, text: plain(text) };
|
||
};
|
||
|
||
it("names the first check-in in the user's time zone", () => {
|
||
const morning = { ...off, morning: "08:00" };
|
||
expect(note(morning, vienna("2026-10-07T04:00:00Z"))).toEqual({
|
||
nextCheckInAt: "2026-10-07T06:00:00.000Z",
|
||
text: "All set. I’ll check in today at 8:00.",
|
||
});
|
||
expect(note(morning, vienna("2026-10-07T09:00:00Z"))).toEqual({
|
||
nextCheckInAt: "2026-10-08T06:00:00.000Z",
|
||
text: "All set. I’ll check in tomorrow at 8:00.",
|
||
});
|
||
expect(note({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z")).text).toBe(
|
||
"All set. I’ll check in today at 18:00.",
|
||
);
|
||
// Wednesday in Vienna; the weekly review is on Sunday.
|
||
const weekly = { ...off, weekly: { day: 0, time: "18:00" } };
|
||
expect(note(weekly, vienna("2026-10-07T09:00:00Z", "en-US"))).toEqual({
|
||
nextCheckInAt: "2026-10-11T16:00:00.000Z",
|
||
text: "All set. I’ll check in on Sunday at 6:00 PM.",
|
||
});
|
||
});
|
||
|
||
it("says the user can just write when no check-in is set", () => {
|
||
expect(closingNote(off, vienna("2026-10-07T09:00:00Z"))).toEqual({
|
||
kind: "intake_note",
|
||
note: "done",
|
||
nextCheckInAt: null,
|
||
text: "All set. I’m here whenever you need me.",
|
||
});
|
||
});
|
||
|
||
it("falls back to English and UTC for an unusable profile", () => {
|
||
expect(intakeContext({ locale: "??", timeZone: "Mars/Olympus" }, new Date(0))).toMatchObject({
|
||
locale: "en",
|
||
timeZone: "UTC",
|
||
});
|
||
});
|
||
});
|