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:
parent
1a3829031f
commit
74eb27447e
@ -1,20 +1,127 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
import { expect, test } 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) => {
|
test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
|
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Heute", exact: true }).click();
|
await openHeute(page);
|
||||||
await expect(page).toHaveURL(/\/app\/[^/]+$/);
|
await expect(page).toHaveURL(/\/app\/[^/]+$/);
|
||||||
|
|
||||||
const brief = page.getByTestId("heute-brief");
|
const brief = page.getByTestId("heute-brief");
|
||||||
await expect(brief).toBeVisible();
|
await expect(brief).toBeVisible();
|
||||||
await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0);
|
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.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-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");
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
214
apps/web/src/components/heute/CoachRhythmControl.tsx
Normal file
214
apps/web/src/components/heute/CoachRhythmControl.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
apps/web/src/components/heute/HeuteCoach.tsx
Normal file
105
apps/web/src/components/heute/HeuteCoach.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 { 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");
|
const now = new Date("2026-09-22T12:00:00.000Z");
|
||||||
|
|
||||||
@ -142,3 +149,59 @@ describe("buildHeuteBrief", () => {
|
|||||||
expect(brief.actionCount).toBe(5);
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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";
|
import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity";
|
||||||
|
|
||||||
export type JetztRunItem = {
|
export type JetztRunItem = {
|
||||||
@ -137,3 +137,38 @@ export function buildHeuteBrief(params: {
|
|||||||
weiter: [],
|
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" });
|
||||||
|
}
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import { i18n } from "@lingui/core";
|
import { i18n } from "@lingui/core";
|
||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
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 { botColors } from "@rakazo/ui-tokens";
|
||||||
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
|
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { CoachRhythmControl } from "../components/heute/CoachRhythmControl";
|
||||||
|
import { HeuteCoach } from "../components/heute/HeuteCoach";
|
||||||
import { runActivityLabel } from "../lib/agent-activity";
|
import { runActivityLabel } from "../lib/agent-activity";
|
||||||
|
import type { DoingItem, DoneItem, JetztItem } from "../lib/heute-brief";
|
||||||
import {
|
import {
|
||||||
buildHeuteBrief,
|
buildHeuteBrief,
|
||||||
cleanBriefPreview,
|
cleanBriefPreview,
|
||||||
type DoingItem,
|
coachRefreshSignal,
|
||||||
type DoneItem,
|
formatLocalDate,
|
||||||
type JetztItem,
|
withHabitDone,
|
||||||
} from "../lib/heute-brief";
|
} from "../lib/heute-brief";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
const MISSION_CONTROL_URL = "http://127.0.0.1:3088/";
|
|
||||||
|
|
||||||
type HeuteBriefProps = {
|
type HeuteBriefProps = {
|
||||||
bots: Bot[];
|
bots: Bot[];
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
@ -26,6 +26,8 @@ type HeuteBriefProps = {
|
|||||||
onStartChat: () => void;
|
onStartChat: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long" };
|
||||||
|
|
||||||
export function HeuteBrief({
|
export function HeuteBrief({
|
||||||
bots,
|
bots,
|
||||||
groups,
|
groups,
|
||||||
@ -37,6 +39,9 @@ export function HeuteBrief({
|
|||||||
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
|
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
|
||||||
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
|
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [coach, setCoach] = useState<CoachToday | null>(null);
|
||||||
|
const [coachSettled, setCoachSettled] = useState(false);
|
||||||
|
const latestCoachRequest = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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(
|
const brief = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildHeuteBrief({
|
buildHeuteBrief({
|
||||||
@ -81,13 +130,23 @@ export function HeuteBrief({
|
|||||||
|
|
||||||
const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]);
|
const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]);
|
||||||
|
|
||||||
const dateLabel = formatHeuteDate(new Date());
|
const locale = i18n.locale || "de";
|
||||||
const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0;
|
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 (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
|
<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">
|
<div className="flex items-start justify-between gap-3 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>
|
<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">
|
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
|
||||||
<StatusLine
|
<StatusLine
|
||||||
actionCount={brief.actionCount}
|
actionCount={brief.actionCount}
|
||||||
@ -96,16 +155,20 @@ export function HeuteBrief({
|
|||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<CoachRhythmControl />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
|
<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">
|
<p className="text-ui-body text-muted-foreground">
|
||||||
<Trans>Loading…</Trans>
|
<Trans>Loading…</Trans>
|
||||||
</p>
|
</p>
|
||||||
) : !hasContent ? (
|
) : !hasContent ? (
|
||||||
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
|
<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 ? (
|
{brief.needsYou.length > 0 ? (
|
||||||
<section data-testid="heute-needs-you">
|
<section data-testid="heute-needs-you">
|
||||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||||
@ -172,19 +235,6 @@ export function HeuteBrief({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -399,12 +449,3 @@ function jetztRowKey(item: JetztItem): string {
|
|||||||
if (item.kind === "run") return `run:${item.run.runId}`;
|
if (item.kind === "run") return `run:${item.run.runId}`;
|
||||||
return `unread:${item.chatKind}:${item.chat.id}`;
|
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
* 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
|
* 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
|
* 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
|
* owner's agent over an agent connection) gets only the basics: the summary, facts, goals and
|
||||||
* with the user.
|
* habits stay with the user.
|
||||||
*/
|
*/
|
||||||
export function formatUserContext(
|
export function formatUserContext(
|
||||||
user: UserContext,
|
user: UserContext,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user