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
215 lines
6.2 KiB
TypeScript
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"
|
|
/>
|
|
);
|
|
}
|