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 { 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",
|
||||
]);
|
||||
});
|
||||
|
||||
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 { 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user