feat(web): coach on the Heute page

Heute only listed runs and linked to a hard-coded local Mission Control
address, so the coach's work had nowhere to show.

- Today's brief at the top, habits with a check-off and their streak,
  and active goals, each only when there is something to show; then the
  existing needs-you, doing and done lists.
- One icon button opens the check-in times (morning, evening, weekly),
  loaded when opened and saved on every change.
- The page reads coach.today, refreshes it when a run finishes or the
  tab becomes visible, and checks habits off optimistically; the date
  header uses the user's time zone from the profile.
- Removes the Mission Control link and its constant.
- Tests: helper unit tests and a web E2E that writes the brief through
  the main bot, checks a habit off across a reload and sets check-in
  times (screenshots heute-coach and heute-rhythm).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 14:09:51 +00:00
parent 1a3829031f
commit 74eb27447e
No known key found for this signature in database
7 changed files with 616 additions and 51 deletions

View File

@ -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<CoachToday>(page, "coach/today", {});
const goal = await rpc<Goal>(page, "coach/goals/create", {
title: "Ship the beta",
targetDate: shiftDate(date, 30),
});
const habit = await rpc<Habit>(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<CoachRhythm>(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<Routine[]>(page, "routines/list", { botId: main.id });
expect(routines.map((routine) => routine.name).sort()).toEqual([
"Morning brief",
"Weekly review",
]);
});

View File

@ -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<CoachRhythm | null>(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 (
<Popover
onOpenChange={(open) => {
if (open) void load();
}}
>
<PopoverTrigger
data-testid="heute-rhythm"
aria-label={t`Check-ins`}
title={t`Check-ins`}
render={<Button variant="ghost" size="icon" />}
>
<CalendarClock strokeWidth={1.7} aria-hidden="true" />
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={8}
data-testid="heute-rhythm-panel"
className="w-[min(340px,calc(100vw-2rem))] gap-3 p-3"
>
{rhythm ? (
<>
<RhythmRow
label={t`Morning`}
testId="heute-rhythm-morning"
on={rhythm.morning !== null}
onToggle={(on) => void save({ ...rhythm, morning: on ? DEFAULTS.morning : null })}
>
{(labelId) =>
rhythm.morning ? (
<TimeInput
labelId={labelId}
value={rhythm.morning}
onChange={(morning) => void save({ ...rhythm, morning })}
/>
) : null
}
</RhythmRow>
<RhythmRow
label={t`Evening`}
testId="heute-rhythm-evening"
on={rhythm.evening !== null}
onToggle={(on) => void save({ ...rhythm, evening: on ? DEFAULTS.evening : null })}
>
{(labelId) =>
rhythm.evening ? (
<TimeInput
labelId={labelId}
value={rhythm.evening}
onChange={(evening) => void save({ ...rhythm, evening })}
/>
) : null
}
</RhythmRow>
<RhythmRow
label={t`Weekly`}
testId="heute-rhythm-weekly"
on={rhythm.weekly !== null}
onToggle={(on) => void save({ ...rhythm, weekly: on ? DEFAULTS.weekly : null })}
>
{(labelId) => {
const weekly = rhythm.weekly;
if (!weekly) return null;
return (
<>
<NativeSelect
size="sm"
aria-labelledby={labelId}
value={String(weekly.day)}
onChange={(event) =>
void save({
...rhythm,
weekly: { ...weekly, day: Number(event.target.value) },
})
}
>
{WEEKDAYS.map((day) => (
<NativeSelectOption key={day} value={day}>
{weekdayName(day)}
</NativeSelectOption>
))}
</NativeSelect>
<TimeInput
labelId={labelId}
value={weekly.time}
onChange={(time) => void save({ ...rhythm, weekly: { ...weekly, time } })}
/>
</>
);
}}
</RhythmRow>
</>
) : (
<div className="flex justify-center py-3">
<Spinner />
</div>
)}
</PopoverContent>
</Popover>
);
}
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 (
<div data-testid={testId} className="flex min-h-control-sm flex-wrap items-center gap-2">
<Switch
size="sm"
checked={on}
aria-labelledby={labelId}
onCheckedChange={(checked) => onToggle(checked)}
/>
<span id={labelId} className="flex-1 text-ui-body text-foreground">
{label}
</span>
<div className="ml-auto flex items-center gap-2">{children(labelId)}</div>
</div>
);
}
function TimeInput({
labelId,
value,
onChange,
}: {
labelId: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<Input
type="time"
aria-labelledby={labelId}
value={value}
onChange={(event) => {
if (event.target.value) onChange(event.target.value.slice(0, 5));
}}
className="h-control-sm w-32 px-2 tabular-nums"
/>
);
}

View File

@ -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 ? (
<section data-testid="heute-coach-brief" className="rounded-2xl bg-muted/60 px-4 py-3.5">
<p className="whitespace-pre-line text-ui-body-lg text-foreground">{today.brief.text}</p>
</section>
) : null}
{today.habits.length > 0 ? (
<section data-testid="heute-habits">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Habits</Trans>
</h2>
<ul className="flex flex-col gap-1">
{today.habits.map((habit) => (
<li key={habit.id}>
<HabitRow habit={habit} onDone={(done) => onHabitDone(habit, done)} />
</li>
))}
</ul>
</section>
) : null}
{today.goals.length > 0 ? (
<section data-testid="heute-goals">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Goals</Trans>
</h2>
<ul className="flex flex-col gap-1">
{today.goals.map((goal) => (
<li
key={goal.id}
data-testid={`heute-goal-${goal.id}`}
className="flex items-baseline justify-between gap-3 px-3 py-2"
>
<span className="min-w-0 truncate text-ui-body text-foreground">{goal.title}</span>
{goal.targetDate ? (
<span className="shrink-0 text-ui-label text-muted-foreground tabular-nums">
{formatLocalDate(goal.targetDate, i18n.locale || "en", {
day: "numeric",
month: "short",
})}
</span>
) : null}
</li>
))}
</ul>
</section>
) : null}
</>
);
}
function HabitRow({ habit, onDone }: { habit: HabitToday; onDone: (done: boolean) => void }) {
const id = useId();
return (
<label
htmlFor={id}
data-testid={`heute-habit-${habit.id}`}
className="flex cursor-pointer items-center gap-3 rounded-2xl px-3 py-2.5 transition-colors hover:bg-accent"
>
<Checkbox id={id} checked={habit.doneToday} onCheckedChange={(checked) => onDone(checked)} />
<span
className={cn(
"min-w-0 flex-1 truncate text-ui-body",
habit.doneToday ? "text-muted-foreground" : "text-foreground",
)}
>
{habit.title}
</span>
{habit.streak >= STREAK_MIN ? (
<span
data-testid={`heute-habit-streak-${habit.id}`}
className="flex shrink-0 items-center gap-1 text-ui-label text-muted-foreground tabular-nums"
>
<Flame strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
{habit.cadence === "weekly" ? (
<Plural value={habit.streak} one="# week" other="# weeks" />
) : (
<Plural value={habit.streak} one="# day" other="# days" />
)}
</span>
) : null}
</label>
);
}

View File

@ -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> = {}): 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",
);
});
});

View File

@ -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" });
}

View File

@ -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<RunActivityRow[]>([]);
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
const [loading, setLoading] = useState(true);
const [coach, setCoach] = useState<CoachToday | null>(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 (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
<div className="border-b border-border px-4 py-5 md:px-8 lg:px-10">
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">{dateLabel}</h1>
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
<StatusLine
actionCount={brief.actionCount}
loading={loading}
doingCount={brief.doing.length}
/>
</p>
<div className="flex items-start justify-between gap-3 border-b border-border px-4 py-5 md:px-8 lg:px-10">
<div className="min-w-0">
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">
{dateLabel}
</h1>
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
<StatusLine
actionCount={brief.actionCount}
loading={loading}
doingCount={brief.doing.length}
/>
</p>
</div>
<CoachRhythmControl />
</div>
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
{loading && !hasContent ? (
{(loading || !coachSettled) && !hasContent ? (
<p className="text-ui-body text-muted-foreground">
<Trans>Loading…</Trans>
</p>
) : !hasContent ? (
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
) : (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8 pb-4">
{coach ? <HeuteCoach today={coach} onHabitDone={setHabitDone} /> : null}
{brief.needsYou.length > 0 ? (
<section data-testid="heute-needs-you">
<h2 className="rk-shell-section-label mb-2 px-0.5">
@ -172,19 +235,6 @@ export function HeuteBrief({
) : null}
</div>
)}
<footer className="mx-auto mt-10 flex w-full max-w-2xl justify-center pb-4">
<a
href={MISSION_CONTROL_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground/70 transition-colors hover:text-muted-foreground"
data-testid="heute-mission-control-link"
>
<Trans>Mission Control</Trans>
<ExternalLink strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
</a>
</footer>
</div>
</div>
);
@ -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",
});
}

View File

@ -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,