diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 9d0e90f..79ad40f 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -5276,6 +5276,7 @@ function spaceGroupDto(group: SpaceGroup): SpaceGroup { sectionId: group.sectionId, members: group.members, preview: group.preview, + ...(group.previewBlock ? { previewBlock: group.previewBlock } : {}), unread: group.unread, updatedAt: group.updatedAt, }; diff --git a/apps/web/e2e/onboarding-intake.spec.ts b/apps/web/e2e/onboarding-intake.spec.ts index ae8967a..7e223cf 100644 --- a/apps/web/e2e/onboarding-intake.spec.ts +++ b/apps/web/e2e/onboarding-intake.spec.ts @@ -39,6 +39,9 @@ test("the main bot asks focus, goal, check-ins and quiet hours, then says what i const rhythm = card(page, "rhythm"); await expect(rhythm).toContainText("When should I check in with you?"); + // The sidebar previews the main conversation as the web words it: the pending question. + const mainEntry = page.locator("aside").first().locator("[data-main-conversation]"); + await expect(mainEntry).toContainText("When should I check in with you?"); await rhythm.scrollIntoViewIfNeeded(); await page.mouse.move(1, 1); await captureScreenshot(page, testInfo, "onboarding-intake-rhythm"); @@ -60,6 +63,7 @@ test("the main bot asks focus, goal, check-ins and quiet hours, then says what i await notifications.getByTestId("notifications-choice-later").click(); } await expect(closing).toBeVisible(); + await expect(mainEntry).toContainText(/All set\. I’ll check in (today|tomorrow) at 8:00\sAM\./); await expect( page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'), ).toHaveCount(0); diff --git a/apps/web/src/lib/onboarding-copy.test.ts b/apps/web/src/lib/onboarding-copy.test.ts index eb8e1c2..b408585 100644 --- a/apps/web/src/lib/onboarding-copy.test.ts +++ b/apps/web/src/lib/onboarding-copy.test.ts @@ -1,21 +1,22 @@ 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"; +import { + intakeNoteText, + localizedChoice, + localizedPreview, + withIntakeNoteText, +} from "./onboarding-copy.js"; -// The macros compile at build time; here they keep the English source. +// The macros compile at build time; here the English source is the id, placeholders numbered. vi.mock("@lingui/core/macro", () => { - const text = (parts: TemplateStringsArray, ...values: unknown[]) => - parts.reduce( - (out, part, index) => out + part + (index < values.length ? values[index] : ""), - "", - ); + const descriptor = (parts: TemplateStringsArray, values: unknown[]) => { + const message = parts.reduce((out, part, index) => `${out}{${index - 1}}${part}`); + return { id: message, message, values: { ...values } }; + }; return { - t: text, - msg: (parts: TemplateStringsArray, ...values: unknown[]) => { - const message = text(parts, ...values); - return { id: message, message }; - }, + t: (parts: TemplateStringsArray, ...values: unknown[]) => i18n._(descriptor(parts, values)), + msg: (parts: TemplateStringsArray, ...values: unknown[]) => descriptor(parts, values), }; }); @@ -138,3 +139,65 @@ describe("withIntakeNoteText", () => { ]); }); }); + +describe("localizedPreview", () => { + const now = new Date(2026, 9, 7, 10, 0); + const rhythm: Choice = { + kind: "choice", + step: "rhythm", + question: "server question", + options: [ + { id: "morning", letter: "A", label: "server morning" }, + { id: "none", letter: "", label: "server none" }, + ], + }; + const goal: Choice = { + kind: "choice", + step: "goal", + question: "server goal", + textInput: { placeholder: "server placeholder" }, + options: [{ id: "skip", letter: "", label: "server skip" }], + }; + const done: Note = { + kind: "intake_note", + note: "done", + nextCheckInAt: new Date(2026, 9, 8, 8, 0).toISOString(), + text: "server text", + }; + const preview = (previewBlock?: Choice | Note) => localizedPreview({ preview: "", previewBlock }); + + beforeEach(() => { + vi.useFakeTimers({ now }); + return () => vi.useRealTimers(); + }); + + it("keeps the server preview when onboarding did not write the latest message", () => { + expect(localizedPreview({ preview: "See you at 8" })).toBe("See you at 8"); + }); + + it("reads like the conversation: the question, the answer once given, the closing note", () => { + expect(preview(rhythm)).toBe("When should I check in with you?"); + expect(preview({ ...rhythm, answerId: "morning" })).toBe("Every morning"); + expect(preview({ ...goal, answerId: "_text", answerText: "Run a 10K" })).toBe("Run a 10K"); + expect(plain(preview(done))).toBe("All set. I’ll check in tomorrow at 8:00 AM."); + // A dismissed card is gone from the conversation, so the preview is too. + expect(preview({ ...rhythm, answerId: "_dismissed" })).toBe(""); + }); + + it("speaks the user's language", () => { + i18n.load("de", { + "When should I check in with you?": "Wann soll ich mich bei dir melden?", + "Every morning": "Jeden Morgen", + "All set. I’ll check in tomorrow at {0}.": "Alles klar. Ich melde mich morgen um {0}.", + "All set. I’m here whenever you need me.": + "Alles klar. Ich bin da, wann immer du mich brauchst.", + }); + i18n.activate("de"); + expect(preview(rhythm)).toBe("Wann soll ich mich bei dir melden?"); + expect(preview({ ...rhythm, answerId: "morning" })).toBe("Jeden Morgen"); + expect(preview(done)).toBe("Alles klar. Ich melde mich morgen um 8:00."); + expect(preview({ ...done, nextCheckInAt: null })).toBe( + "Alles klar. Ich bin da, wann immer du mich brauchst.", + ); + }); +}); diff --git a/apps/web/src/lib/onboarding-copy.ts b/apps/web/src/lib/onboarding-copy.ts index 1eb077d..839110f 100644 --- a/apps/web/src/lib/onboarding-copy.ts +++ b/apps/web/src/lib/onboarding-copy.ts @@ -1,6 +1,7 @@ import type { MessageDescriptor } from "@lingui/core"; import { msg, t } from "@lingui/core/macro"; -import type { MessageBlock, OnboardingStep, ThreadMessage } from "@rakazo/contracts"; +import type { Group, MessageBlock, OnboardingStep, ThreadMessage } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { i18n } from "./i18n"; /** @@ -10,6 +11,7 @@ import { i18n } from "./i18n"; */ type ChoiceBlock = Extract; +type ChoiceOption = ChoiceBlock["options"][number]; type IntakeNote = Extract; const QUESTIONS: Record = { @@ -73,6 +75,13 @@ export function localizedChoice(card: ChoiceBlock): ChoiceBlock { }; } +/** What the user answered: the option they picked or their own words. */ +export function chosenOption(card: ChoiceBlock): ChoiceOption | undefined { + return card.answerText + ? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: card.answerText } + : card.options.find((option) => option.id === card.answerId); +} + function sameDay(left: Date, right: Date): boolean { return left.toDateString() === right.toDateString(); } @@ -105,3 +114,17 @@ export function withIntakeNoteText(message: ThreadMessage): ThreadMessage { ), }; } + +/** + * A conversation's preview in the user's language. An onboarding card reads as its question, or + * as the answer once given; the closing note as the note. A dismissed card shows nothing in the + * conversation, so it keeps the server preview. + */ +export function localizedPreview(chat: Pick): string { + const block = chat.previewBlock; + if (!block) return chat.preview; + if (block.kind === "intake_note") return intakeNoteText(block); + const card = localizedChoice(block); + if (!card.answerId) return card.question; + return chosenOption(card)?.label ?? chat.preview; +} diff --git a/apps/web/src/pages/HeuteBrief.tsx b/apps/web/src/pages/HeuteBrief.tsx index c23ed72..4bef9db 100644 --- a/apps/web/src/pages/HeuteBrief.tsx +++ b/apps/web/src/pages/HeuteBrief.tsx @@ -15,6 +15,7 @@ import { formatLocalDate, withHabitDone, } from "../lib/heute-brief"; +import { localizedPreview } from "../lib/onboarding-copy"; import { rpc } from "../lib/rpc"; type HeuteBriefProps = { @@ -336,7 +337,7 @@ function NeedsYouRow({ const { chatKind, chat } = item; const title = chat.name; - const preview = cleanBriefPreview(chat.preview) || t`Unread reply`; + const preview = cleanBriefPreview(localizedPreview(chat)) || t`Unread reply`; const label = chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`; return ( diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 6c64240..a07547f 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -216,7 +216,7 @@ import { lazyWithRecovery } from "../lib/lazy-recovery"; import { localTimezone } from "../lib/local-timezone"; import { copyableMessageText } from "../lib/message-text"; import { messageProviderLabel } from "../lib/messaging"; -import { withIntakeNoteText } from "../lib/onboarding-copy"; +import { localizedPreview, withIntakeNoteText } from "../lib/onboarding-copy"; import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire"; import { ooSound } from "../lib/oo-sound"; import { ooVocals } from "../lib/oo-vocals"; @@ -3655,7 +3655,7 @@ export function ShellPage() { time={formatRosterTime(mainConversationEntry.group.updatedAt)} subtitle={rosterActivitySubtitle( mainConversationEntry.bot.status, - cleanRosterPreview(mainConversationEntry.group.preview), + cleanRosterPreview(localizedPreview(mainConversationEntry.group)), )} active={inGroup && groupId === mainConversationEntry.groupId} onOpen={openMainConversation} @@ -4169,7 +4169,7 @@ export function ShellPage() { item.chat.unread, )}`} > - {cleanRosterPreview(item.chat.preview)} + {cleanRosterPreview(localizedPreview(item.chat))} diff --git a/apps/web/src/pages/shell/message-cards.tsx b/apps/web/src/pages/shell/message-cards.tsx index 8cbda50..a6e7bca 100644 --- a/apps/web/src/pages/shell/message-cards.tsx +++ b/apps/web/src/pages/shell/message-cards.tsx @@ -19,7 +19,7 @@ import { BuiCard, SuccessPop } from "../../components/ai/primitives"; import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open"; import { chartViewport } from "../../lib/chart-viewport"; import { connectMcpOauth } from "../../lib/mcp-connect"; -import { localizedChoice } from "../../lib/onboarding-copy"; +import { chosenOption, localizedChoice } from "../../lib/onboarding-copy"; import { rpc } from "../../lib/rpc"; import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push"; @@ -91,9 +91,7 @@ export function ChoiceCard({ if (dismissed) return null; - const chosen = block.answerText - ? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: block.answerText } - : card.options.find((option) => option.id === block.answerId); + const chosen = chosenOption(card); return (
diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 5caa284..d7c2fc3 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -1,6 +1,6 @@ import * as z from "zod"; import { CoachRoutineKeySchema } from "./coach.js"; -import { DelegatedTaskStatusSchema, ThreadMessageSchema } from "./events.js"; +import { DelegatedTaskStatusSchema, IntakeBlockSchema, ThreadMessageSchema } from "./events.js"; import { Id, MemoryScope, RunStatus, SandboxKind } from "./ids.js"; import { McpHeadersSchema, McpRemoteEndpointSchema, McpTransportSchema } from "./mcp.js"; @@ -130,6 +130,8 @@ export const GroupSchema = z.object({ members: z.array(GroupMemberSchema), threadId: Id, preview: z.string(), + /** The latest message when it is an onboarding card or note; the web words it from this. */ + previewBlock: IntakeBlockSchema.optional(), unread: z.boolean(), updatedAt: z.string(), createdAt: z.string(), @@ -202,6 +204,7 @@ export const SpaceGroupSchema = GroupSchema.pick({ sectionId: true, members: true, preview: true, + previewBlock: true, unread: true, updatedAt: true, }); diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index ad7918b..5db6c34 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -137,6 +137,41 @@ export const AskBlockSchema = z.object({ .optional(), }); +export const ChoiceBlockSchema = z.object({ + kind: z.literal("choice"), + question: z.string(), + subtitle: z.string().optional(), + /** An option without a letter is a secondary answer such as "Skip". */ + options: z.array(z.object({ id: z.string(), letter: z.string(), label: z.string() })), + /** The onboarding step this card asks; absent on a specialist's focus card. */ + step: OnboardingStepSchema.optional(), + /** Lets the user answer in their own words. */ + textInput: z.object({ placeholder: z.string() }).optional(), + /** Set once the user picks an option; renders the picker as answered. */ + answerId: z.string().optional(), + /** The user's words when they answered through the text input. */ + answerText: z.string().optional(), +}); + +/** + * A line onboarding writes. Clients that know `note` say it in the user's language and + * write the time where they are; everything else (mobile, agent history) uses `text`. + */ +export const IntakeNoteBlockSchema = z.object({ + kind: z.literal("intake_note"), + /** "done": onboarding is complete; the first check-in follows at `nextCheckInAt`. */ + note: z.enum(["done"]), + nextCheckInAt: z.string().nullable(), + text: z.string(), +}); + +/** What onboarding writes into a conversation: its cards and its closing note. */ +export const IntakeBlockSchema = z.discriminatedUnion("kind", [ + ChoiceBlockSchema, + IntakeNoteBlockSchema, +]); +export type IntakeBlock = z.infer; + export const MessageBlock = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("text"), text: z.string() }), z.object({ @@ -144,32 +179,8 @@ export const MessageBlock = z.discriminatedUnion("kind", [ lines: z.array(z.object({ k: z.string(), v: z.string() })), }), AskBlockSchema, - z.object({ - kind: z.literal("choice"), - question: z.string(), - subtitle: z.string().optional(), - /** An option without a letter is a secondary answer such as "Skip". */ - options: z.array(z.object({ id: z.string(), letter: z.string(), label: z.string() })), - /** The onboarding step this card asks; absent on a specialist's focus card. */ - step: OnboardingStepSchema.optional(), - /** Lets the user answer in their own words. */ - textInput: z.object({ placeholder: z.string() }).optional(), - /** Set once the user picks an option; renders the picker as answered. */ - answerId: z.string().optional(), - /** The user's words when they answered through the text input. */ - answerText: z.string().optional(), - }), - z.object({ - /** - * A line onboarding writes. Clients that know `note` say it in the user's language and - * write the time where they are; everything else (mobile, agent history) uses `text`. - */ - kind: z.literal("intake_note"), - /** "done": onboarding is complete; the first check-in follows at `nextCheckInAt`. */ - note: z.enum(["done"]), - nextCheckInAt: z.string().nullable(), - text: z.string(), - }), + ChoiceBlockSchema, + IntakeNoteBlockSchema, z.object({ /** Inline app authorization card (Composio-backed): logo, name, one-line description, and an Authorize button that flips to connected. */ diff --git a/packages/db/src/groups.test.ts b/packages/db/src/groups.test.ts index d8c42e2..7b1ff8c 100644 --- a/packages/db/src/groups.test.ts +++ b/packages/db/src/groups.test.ts @@ -63,6 +63,77 @@ describe("listSpaceGroupsForSpaces", () => { }); }); +describe("group previews", () => { + const actor = { + spaceId: "workspace-1", + userId: "user-1", + email: "user@example.test", + isDeploymentOwner: false, + }; + const card = { + kind: "choice", + step: "rhythm", + question: "When should I check in with you?", + options: [{ id: "morning", letter: "A", label: "Every morning" }], + }; + const note = { + kind: "intake_note", + note: "done", + nextCheckInAt: "2026-10-05T06:00:00.000Z", + text: "All set. I’ll check in tomorrow at 8:00 AM.", + }; + const latest: Record = { + card: [card], + note: [note], + text: [{ kind: "text", text: "Hello" }], + malformed: [{ kind: "choice", question: 1 }], + empty: undefined, + }; + const records = Object.entries(latest).map(([id, blocks]) => ({ + id, + spaceId: actor.spaceId, + userId: actor.userId, + kind: "main", + name: "Chief", + leadBotId: "bot-1", + pinned: false, + sectionId: null, + archivedAt: null, + createdAt: new Date("2026-10-04T00:00:00.000Z"), + updatedAt: new Date("2026-10-04T00:00:00.000Z"), + thread: { id: `thread-${id}`, unread: false, messages: blocks ? [{ blocks }] : [] }, + members: [{ bot: { id: "bot-1", name: "Chief", color: "#111", runs: [] } }], + })); + + it.each(["listGroups", "listSpaceGroupsForSpaces"] as const)( + "%s keeps the latest onboarding card or note next to the preview", + async (list) => { + const repos = createGroupRepos({ + chatGroup: { findMany: vi.fn(async () => records) }, + } as unknown as PrismaClient); + const groups = + list === "listGroups" + ? await repos.listGroups(actor) + : await repos.listSpaceGroupsForSpaces(actor, [actor.spaceId]); + + expect( + groups.map(({ id, preview, previewBlock }) => ({ id, preview, previewBlock })), + ).toEqual([ + { id: "card", preview: "", previewBlock: card }, + { id: "note", preview: note.text, previewBlock: note }, + { id: "text", preview: "Hello", previewBlock: undefined }, + { id: "malformed", preview: "", previewBlock: undefined }, + { id: "empty", preview: "", previewBlock: undefined }, + ]); + // Everything else leaves the field out, so the wire carries nothing extra. + expect(groups.filter((group) => "previewBlock" in group).map(({ id }) => id)).toEqual([ + "card", + "note", + ]); + }, + ); +}); + describe("archiveGroup", () => { const actor = { spaceId: "workspace-1", diff --git a/packages/db/src/groups.ts b/packages/db/src/groups.ts index 36f0332..9f99e33 100644 --- a/packages/db/src/groups.ts +++ b/packages/db/src/groups.ts @@ -15,7 +15,12 @@ import type { Prisma, PrismaClient } from "./client.js"; import { expireComputerExecutionLeases } from "./computers.js"; import { IsolationError } from "./scope.js"; import { lockSpaceForContentCreation } from "./spaces.js"; -import { activeRunSelection, activeRunStatuses, previewFromBlocks } from "./thread-listing.js"; +import { + activeRunSelection, + activeRunStatuses, + groupPreview, + previewFromBlocks, +} from "./thread-listing.js"; type GroupRecord = { id: string; @@ -65,7 +70,6 @@ function mapGroupMembers(members: GroupRecord["members"]): GroupMember[] { function mapGroup(group: GroupRecord): Group { if (!group.thread) throw new IsolationError("Group is missing its thread"); - const preview = previewFromBlocks(group.thread.messages[0]?.blocks); return { id: group.id, spaceId: group.spaceId, @@ -77,7 +81,7 @@ function mapGroup(group: GroupRecord): Group { archivedAt: group.archivedAt?.toISOString() ?? null, members: mapGroupMembers(group.members), threadId: group.thread.id, - preview, + ...groupPreview(group.thread.messages[0]?.blocks), unread: group.thread.unread, updatedAt: group.updatedAt.toISOString(), createdAt: group.createdAt.toISOString(), @@ -112,7 +116,7 @@ function mapSpaceGroup(group: SpaceGroupRecord): SpaceGroup { pinned: group.pinned, sectionId: group.sectionId, members: mapGroupMembers(group.members), - preview: previewFromBlocks(group.thread.messages[0]?.blocks), + ...groupPreview(group.thread.messages[0]?.blocks), unread: group.thread.unread, updatedAt: group.updatedAt.toISOString(), }; diff --git a/packages/db/src/thread-listing.ts b/packages/db/src/thread-listing.ts index 853eb6d..726456a 100644 --- a/packages/db/src/thread-listing.ts +++ b/packages/db/src/thread-listing.ts @@ -1,3 +1,5 @@ +import type { Group } from "@rakazo/contracts"; +import { IntakeBlockSchema } from "@rakazo/contracts"; import { ACTIVE_RUN_STATUSES } from "@rakazo/core"; export const activeRunStatuses = [...ACTIVE_RUN_STATUSES]; @@ -23,3 +25,15 @@ export function previewFromBlocks(blocks: unknown): string { } return ""; } + +/** + * A group's preview of its latest message. An onboarding card or note comes along as + * `previewBlock`, so the web can word the preview in the user's language. + */ +export function groupPreview(blocks: unknown): Pick { + const intake = IntakeBlockSchema.safeParse(Array.isArray(blocks) ? blocks[0] : undefined); + return { + preview: previewFromBlocks(blocks), + ...(intake.success ? { previewBlock: intake.data } : {}), + }; +} diff --git a/packages/testkit/src/onboarding-intake.postgres.test.ts b/packages/testkit/src/onboarding-intake.postgres.test.ts index b65f6d2..6825fc2 100644 --- a/packages/testkit/src/onboarding-intake.postgres.test.ts +++ b/packages/testkit/src/onboarding-intake.postgres.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { ComposioEmulator, getCoachRhythm } from "@rakazo/adapters"; -import type { MessageBlock } from "@rakazo/contracts"; +import type { MessageBlock, SpaceNavigation } from "@rakazo/contracts"; import { getNotificationSettings, loadUserContext } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { createApp } from "../../../apps/api/src/app.ts"; @@ -59,7 +59,26 @@ describeWithDatabase("onboarding questions in the main conversation", () => { where: { groupId: conversation.groupId }, select: { id: true }, }); - return { cookie, userId: me.userId, spaceId: me.spaceId, botId: main.id, threadId }; + return { + cookie, + userId: me.userId, + spaceId: me.spaceId, + botId: main.id, + groupId: conversation.groupId, + threadId, + }; + } + + /** The main conversation's preview as the sidebar lists it, in its space and across spaces. */ + async function listedPreview(user: { cookie: string; groupId: string }) { + const { current, spaces } = await rpc(app, user.cookie, "spaces/list"); + const previews = [current.groups, ...spaces.map((space) => space.groups)] + .flat() + .filter((group) => group.id === user.groupId) + .map(({ preview, previewBlock }) => ({ preview, previewBlock })); + expect(previews).toHaveLength(2); + expect(previews[1]).toEqual(previews[0]); + return previews[0]; } async function messages(threadId: string) { @@ -91,6 +110,11 @@ describeWithDatabase("onboarding questions in the main conversation", () => { await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["focus"]); expect(await onboardingRow(ada.userId)).toMatchObject({ step: "focus", completedAt: null }); + // The card has no text of its own; the web words the preview from the card. + expect(await listedPreview(ada)).toEqual({ + preview: "", + previewBlock: (await pending(ada.threadId))[0], + }); expect( (await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId })) .onboardingOpen, @@ -161,6 +185,7 @@ describeWithDatabase("onboarding questions in the main conversation", () => { expect(closing.text.replace(/\s/g, " ")).toMatch( /^All set\. I’ll check in (today|tomorrow) at 8:00 AM\.$/, ); + expect(await listedPreview(ada)).toEqual({ preview: closing.text, previewBlock: closing }); const firstCheckIn = new Date(closing.nextCheckInAt ?? ""); expect(firstCheckIn.getTime()).toBeGreaterThan(Date.now()); expect(