diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index 28bf669..c473c98 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -1,20 +1,127 @@ +import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; -import { captureScreenshot, completeOnboarding, signup } from "./helpers"; +import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts"; +import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; + +/** The nav button reads "Heute" or "Heute, N need you". */ +function openHeute(page: Page) { + return page.getByRole("button", { name: /^Heute(,|$)/ }).click(); +} + +function shiftDate(date: string, days: number) { + return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); +} test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute"); await completeOnboarding(page); - await page.getByRole("button", { name: "Heute", exact: true }).click(); + await openHeute(page); await expect(page).toHaveURL(/\/app\/[^/]+$/); const brief = page.getByTestId("heute-brief"); await expect(brief).toBeVisible(); await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0); await expect(page.getByText("evOo Mission Control")).toHaveCount(0); + await expect(page.getByTestId("heute-mission-control-link")).toHaveCount(0); await expect(page.getByTestId("heute-status")).toBeVisible(); - await expect(page.getByTestId("heute-mission-control-link")).toBeVisible(); + // Nothing tracked yet: no coach sections. + await expect(page.getByTestId("heute-coach-brief")).toHaveCount(0); + await expect(page.getByTestId("heute-habits")).toHaveCount(0); await captureScreenshot(page, testInfo, "heute-native-brief"); }); + +test("Heute shows the coach's brief, habits and goals, checks habits off and sets check-ins", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `heute-coach-${stamp}@rakazo.test`, "password12", "Heute Coach"); + await completeOnboarding(page); + + // The main bot writes today's brief through its tool. + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.fill("write today's brief: Two calm hours on the beta, then a walk."); + await page.keyboard.press("Enter"); + await expect(page.getByText("today's brief is ready.").first()).toBeVisible({ timeout: 30_000 }); + + const { date } = await rpc(page, "coach/today", {}); + const goal = await rpc(page, "coach/goals/create", { + title: "Ship the beta", + targetDate: shiftDate(date, 30), + }); + const habit = await rpc(page, "coach/habits/create", { title: "Walk 20 minutes" }); + for (const day of [1, 2]) { + await rpc(page, "coach/checkIns/log", { + kind: "habit", + habitId: habit.id, + date: shiftDate(date, -day), + done: true, + }); + } + + await openHeute(page); + await expect(page.getByTestId("heute-coach-brief")).toHaveText( + "Two calm hours on the beta, then a walk.", + ); + await expect(page.getByTestId(`heute-goal-${goal.id}`)).toContainText("Ship the beta"); + const habitRow = page.getByTestId(`heute-habit-${habit.id}`); + await expect(habitRow).toContainText("Walk 20 minutes"); + await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("2 days"); + + const checkbox = habitRow.getByRole("checkbox"); + await expect(checkbox).not.toBeChecked(); + const logged = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/checkIns/log") && response.ok(), + ); + await checkbox.click(); + await logged; + await expect(checkbox).toBeChecked(); + await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days"); + await captureScreenshot(page, testInfo, "heute-coach"); + + await page.reload(); + await openHeute(page); + await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked(); + + // Check-in times live behind one compact control. + await page.getByTestId("heute-rhythm").click(); + const panel = page.getByTestId("heute-rhythm-panel"); + await expect(panel).toBeVisible(); + const morning = panel.getByTestId("heute-rhythm-morning"); + await expect(morning.getByRole("switch")).not.toBeChecked(); + const enabled = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await morning.getByRole("switch").click(); + await enabled; + const time = morning.locator('input[type="time"]'); + await expect(time).toHaveValue("08:00"); + const moved = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await time.fill("07:30"); + await moved; + await expect(time).toHaveValue("07:30"); + const weekly = panel.getByTestId("heute-rhythm-weekly"); + const weeklyOn = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await weekly.getByRole("switch").click(); + await weeklyOn; + await expect(weekly.getByRole("combobox")).toHaveValue("0"); + await captureScreenshot(page, testInfo, "heute-rhythm"); + + expect(await rpc(page, "coach/rhythm/get", {})).toEqual({ + morning: "07:30", + evening: null, + weekly: { day: 0, time: "18:00" }, + }); + const main = await rpc<{ id: string }>(page, "bots/ensureMain", {}); + const routines = await rpc(page, "routines/list", { botId: main.id }); + expect(routines.map((routine) => routine.name).sort()).toEqual([ + "Morning brief", + "Weekly review", + ]); +}); diff --git a/apps/web/src/components/heute/CoachRhythmControl.tsx b/apps/web/src/components/heute/CoachRhythmControl.tsx new file mode 100644 index 0000000..102c085 --- /dev/null +++ b/apps/web/src/components/heute/CoachRhythmControl.tsx @@ -0,0 +1,214 @@ +import { i18n } from "@lingui/core"; +import { useLingui } from "@lingui/react/macro"; +import type { CoachRhythm } from "@rakazo/contracts"; +import { + Button, + Input, + NativeSelect, + NativeSelectOption, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, + Switch, +} from "@rakazo/ui-web"; +import { CalendarClock } from "lucide-react"; +import type { ReactNode } from "react"; +import { useCallback, useId, useRef, useState } from "react"; +import { rpc } from "../../lib/rpc"; + +/** Times a check-in starts at when it is switched on. */ +const DEFAULTS = { morning: "08:00", evening: "20:00", weekly: { day: 0, time: "18:00" } }; +const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; + +function weekdayName(day: number): string { + // 2026-10-04 was a Sunday, so day 0 maps to Sunday. + return new Date(Date.UTC(2026, 9, 4 + day)).toLocaleDateString(i18n.locale || "en", { + weekday: "long", + timeZone: "UTC", + }); +} + +/** When the main bot checks in: loaded when opened, saved on every change. */ +export function CoachRhythmControl() { + const { t } = useLingui(); + const [rhythm, setRhythm] = useState(null); + const latestRequest = useRef(0); + + const load = useCallback(async () => { + const request = ++latestRequest.current; + try { + const loaded = await rpc.coach.rhythm.get(); + if (request === latestRequest.current) setRhythm(loaded); + } catch { + // Opening the control again retries. + } + }, []); + + const save = useCallback( + async (next: CoachRhythm) => { + setRhythm(next); + const request = ++latestRequest.current; + try { + const saved = await rpc.coach.rhythm.set(next); + if (request === latestRequest.current) setRhythm(saved); + } catch { + if (request === latestRequest.current) void load(); + } + }, + [load], + ); + + return ( + { + if (open) void load(); + }} + > + } + > + + + {rhythm ? ( + <> + void save({ ...rhythm, morning: on ? DEFAULTS.morning : null })} + > + {(labelId) => + rhythm.morning ? ( + void save({ ...rhythm, morning })} + /> + ) : null + } + + void save({ ...rhythm, evening: on ? DEFAULTS.evening : null })} + > + {(labelId) => + rhythm.evening ? ( + void save({ ...rhythm, evening })} + /> + ) : null + } + + void save({ ...rhythm, weekly: on ? DEFAULTS.weekly : null })} + > + {(labelId) => { + const weekly = rhythm.weekly; + if (!weekly) return null; + return ( + <> + + void save({ + ...rhythm, + weekly: { ...weekly, day: Number(event.target.value) }, + }) + } + > + {WEEKDAYS.map((day) => ( + + {weekdayName(day)} + + ))} + + void save({ ...rhythm, weekly: { ...weekly, time } })} + /> + + ); + }} + + + ) : ( +
+ +
+ )} +
+
+ ); +} + +function RhythmRow({ + label, + testId, + on, + onToggle, + children, +}: { + label: string; + testId: string; + on: boolean; + onToggle: (on: boolean) => void; + children: (labelId: string) => ReactNode; +}) { + const labelId = useId(); + return ( +
+ onToggle(checked)} + /> + + {label} + +
{children(labelId)}
+
+ ); +} + +function TimeInput({ + labelId, + value, + onChange, +}: { + labelId: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + { + if (event.target.value) onChange(event.target.value.slice(0, 5)); + }} + className="h-control-sm w-32 px-2 tabular-nums" + /> + ); +} diff --git a/apps/web/src/components/heute/HeuteCoach.tsx b/apps/web/src/components/heute/HeuteCoach.tsx new file mode 100644 index 0000000..c88a3c9 --- /dev/null +++ b/apps/web/src/components/heute/HeuteCoach.tsx @@ -0,0 +1,105 @@ +import { i18n } from "@lingui/core"; +import { Plural, Trans } from "@lingui/react/macro"; +import type { CoachToday, HabitToday } from "@rakazo/contracts"; +import { Checkbox, cn } from "@rakazo/ui-web"; +import { Flame } from "lucide-react"; +import { useId } from "react"; +import { formatLocalDate } from "../../lib/heute-brief"; + +/** Shown once a habit has been kept at least twice in a row. */ +const STREAK_MIN = 2; + +/** Today's brief, habits to check off and active goals; each part only when it has content. */ +export function HeuteCoach({ + today, + onHabitDone, +}: { + today: CoachToday; + onHabitDone: (habit: HabitToday, done: boolean) => void; +}) { + return ( + <> + {today.brief ? ( +
+

{today.brief.text}

+
+ ) : null} + + {today.habits.length > 0 ? ( +
+

+ Habits +

+
    + {today.habits.map((habit) => ( +
  • + onHabitDone(habit, done)} /> +
  • + ))} +
+
+ ) : null} + + {today.goals.length > 0 ? ( +
+

+ Goals +

+
    + {today.goals.map((goal) => ( +
  • + {goal.title} + {goal.targetDate ? ( + + {formatLocalDate(goal.targetDate, i18n.locale || "en", { + day: "numeric", + month: "short", + })} + + ) : null} +
  • + ))} +
+
+ ) : null} + + ); +} + +function HabitRow({ habit, onDone }: { habit: HabitToday; onDone: (done: boolean) => void }) { + const id = useId(); + return ( + + ); +} diff --git a/apps/web/src/lib/heute-brief.test.ts b/apps/web/src/lib/heute-brief.test.ts index d023919..18e85e9 100644 --- a/apps/web/src/lib/heute-brief.test.ts +++ b/apps/web/src/lib/heute-brief.test.ts @@ -1,6 +1,13 @@ -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts"; import { describe, expect, it } from "vitest"; -import { buildHeuteBrief, HEUTE_NEEDS_YOU_CAP, isToday } from "./heute-brief"; +import { + buildHeuteBrief, + coachRefreshSignal, + formatLocalDate, + HEUTE_NEEDS_YOU_CAP, + isToday, + withHabitDone, +} from "./heute-brief"; const now = new Date("2026-09-22T12:00:00.000Z"); @@ -142,3 +149,59 @@ describe("buildHeuteBrief", () => { expect(brief.actionCount).toBe(5); }); }); + +function habit(overrides: Partial = {}): HabitToday { + return { + id: "habit-1", + title: "Walk", + cadence: "daily", + goalId: null, + active: true, + source: "user", + createdByBotId: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + doneToday: false, + streak: 3, + ...overrides, + }; +} + +describe("coach on the Heute page", () => { + it("reloads the coach when a run finishes, not while runs are only working", () => { + const working = [run({ runId: "run-1", status: "running" })]; + expect(coachRefreshSignal(working)).toBe(""); + expect(coachRefreshSignal([...working, run({ runId: "run-2", status: "completed" })])).toBe( + "run-2", + ); + }); + + it("checks a habit off before the server confirms, moving the streak by one", () => { + const today: CoachToday = { + date: "2026-10-03", + timeZone: "Europe/Vienna", + brief: null, + goals: [], + habits: [habit(), habit({ id: "habit-2", doneToday: true, streak: 1 })], + checkIn: null, + }; + const checked = withHabitDone(today, "habit-1", true); + expect(checked.habits.map(({ doneToday, streak }) => ({ doneToday, streak }))).toEqual([ + { doneToday: true, streak: 4 }, + { doneToday: true, streak: 1 }, + ]); + expect(withHabitDone(checked, "habit-2", false).habits[1]).toMatchObject({ + doneToday: false, + streak: 0, + }); + // Repeating the same state changes nothing. + expect(withHabitDone(checked, "habit-1", true)).toEqual(checked); + }); + + it("labels the user's calendar date without shifting it into the browser's zone", () => { + expect(formatLocalDate("2026-10-03", "en-US", { weekday: "long" })).toBe("Saturday"); + expect(formatLocalDate("2026-11-01", "en-US", { month: "short", day: "numeric" })).toBe( + "Nov 1", + ); + }); +}); diff --git a/apps/web/src/lib/heute-brief.ts b/apps/web/src/lib/heute-brief.ts index dfebb61..a102370 100644 --- a/apps/web/src/lib/heute-brief.ts +++ b/apps/web/src/lib/heute-brief.ts @@ -1,4 +1,4 @@ -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, RunActivityRow } from "@rakazo/contracts"; import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity"; export type JetztRunItem = { @@ -137,3 +137,38 @@ export function buildHeuteBrief(params: { weiter: [], }; } + +/** + * Finished runs are when the coach learns something (a check-in, today's brief), so a change in + * them is the cue to reload the coach's view of the day. + */ +export function coachRefreshSignal(recentRuns: RunActivityRow[]): string { + return recentRuns + .filter((run) => run.status === "completed") + .map((run) => run.runId) + .join(","); +} + +/** + * A habit checked or unchecked before the server confirms. The current period only counts + * toward the streak once it is done, so the streak moves by one. + */ +export function withHabitDone(today: CoachToday, habitId: string, done: boolean): CoachToday { + return { + ...today, + habits: today.habits.map((habit) => + habit.id !== habitId || habit.doneToday === done + ? habit + : { ...habit, doneToday: done, streak: Math.max(0, habit.streak + (done ? 1 : -1)) }, + ), + }; +} + +/** A calendar date ("YYYY-MM-DD") as a label, independent of the browser's time zone. */ +export function formatLocalDate( + date: string, + locale: string, + options: Intl.DateTimeFormatOptions, +): string { + return new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, { ...options, timeZone: "UTC" }); +} diff --git a/apps/web/src/pages/HeuteBrief.tsx b/apps/web/src/pages/HeuteBrief.tsx index cdf12b7..1511736 100644 --- a/apps/web/src/pages/HeuteBrief.tsx +++ b/apps/web/src/pages/HeuteBrief.tsx @@ -1,22 +1,22 @@ import { i18n } from "@lingui/core"; import { Plural, Trans, useLingui } from "@lingui/react/macro"; -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts"; import { botColors } from "@rakazo/ui-tokens"; import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web"; -import { ExternalLink } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CoachRhythmControl } from "../components/heute/CoachRhythmControl"; +import { HeuteCoach } from "../components/heute/HeuteCoach"; import { runActivityLabel } from "../lib/agent-activity"; +import type { DoingItem, DoneItem, JetztItem } from "../lib/heute-brief"; import { buildHeuteBrief, cleanBriefPreview, - type DoingItem, - type DoneItem, - type JetztItem, + coachRefreshSignal, + formatLocalDate, + withHabitDone, } from "../lib/heute-brief"; import { rpc } from "../lib/rpc"; -const MISSION_CONTROL_URL = "http://127.0.0.1:3088/"; - type HeuteBriefProps = { bots: Bot[]; groups: Group[]; @@ -26,6 +26,8 @@ type HeuteBriefProps = { onStartChat: () => void; }; +const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long" }; + export function HeuteBrief({ bots, groups, @@ -37,6 +39,9 @@ export function HeuteBrief({ const [activeRuns, setActiveRuns] = useState([]); const [recentRuns, setRecentRuns] = useState([]); const [loading, setLoading] = useState(true); + const [coach, setCoach] = useState(null); + const [coachSettled, setCoachSettled] = useState(false); + const latestCoachRequest = useRef(0); useEffect(() => { let cancelled = false; @@ -68,6 +73,50 @@ export function HeuteBrief({ }; }, []); + const refreshCoach = useCallback(async () => { + const request = ++latestCoachRequest.current; + try { + const today = await rpc.coach.today(); + if (request === latestCoachRequest.current) setCoach(today); + } catch { + // Keep the last view; the next refresh retries. + } finally { + setCoachSettled(true); + } + }, []); + + // `coachSignal` is the trigger: a finished run may have logged a check-in or written the brief. + const coachSignal = useMemo(() => coachRefreshSignal(recentRuns), [recentRuns]); + useEffect(() => { + void refreshCoach(); + }, [refreshCoach, coachSignal]); + + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === "visible") void refreshCoach(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + document.removeEventListener("visibilitychange", onVisible); + // Ignore responses that arrive after unmount. + latestCoachRequest.current += 1; + }; + }, [refreshCoach]); + + const setHabitDone = useCallback( + async (habit: HabitToday, done: boolean) => { + if (!coach) return; + setCoach((current) => (current ? withHabitDone(current, habit.id, done) : current)); + latestCoachRequest.current += 1; + try { + await rpc.coach.checkIns.log({ kind: "habit", habitId: habit.id, date: coach.date, done }); + } finally { + void refreshCoach(); + } + }, + [coach, refreshCoach], + ); + const brief = useMemo( () => buildHeuteBrief({ @@ -81,31 +130,45 @@ export function HeuteBrief({ const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]); - const dateLabel = formatHeuteDate(new Date()); - const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0; + const locale = i18n.locale || "de"; + const dateLabel = coach + ? formatLocalDate(coach.date, locale, DATE_LABEL) + : new Date().toLocaleDateString(locale, DATE_LABEL); + const hasCoachContent = Boolean( + coach && (coach.brief || coach.habits.length > 0 || coach.goals.length > 0), + ); + const hasContent = + hasCoachContent || brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0; return (
-
-

{dateLabel}

-

- -

+
+
+

+ {dateLabel} +

+

+ +

+
+
- {loading && !hasContent ? ( + {(loading || !coachSettled) && !hasContent ? (

Loading…

) : !hasContent ? ( 0} /> ) : ( -
+
+ {coach ? : null} + {brief.needsYou.length > 0 ? (

@@ -172,19 +235,6 @@ export function HeuteBrief({ ) : null}

)} - -
); @@ -399,12 +449,3 @@ function jetztRowKey(item: JetztItem): string { if (item.kind === "run") return `run:${item.run.runId}`; return `unread:${item.chatKind}:${item.chat.id}`; } - -function formatHeuteDate(date: Date): string { - const locale = i18n.locale || "de"; - return date.toLocaleDateString(locale, { - weekday: "long", - day: "numeric", - month: "long", - }); -} diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 09704a4..5bdddea 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -79,8 +79,8 @@ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): * by the user or inferred from conversations, so every value is escaped and the block says * it never carries instructions. Empty fields are left out, and with nothing to say the block * is omitted. A run whose replies other people read (a shared messaging channel, or another - * owner's agent over an agent connection) gets only the basics: the summary and facts stay - * with the user. + * owner's agent over an agent connection) gets only the basics: the summary, facts, goals and + * habits stay with the user. */ export function formatUserContext( user: UserContext,