evOo-local/apps/web/src/components/heute/CoachRhythmControl.tsx
Claude 74eb27447e
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
2026-10-03 14:09:51 +00:00

215 lines
6.2 KiB
TypeScript

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