evOo-local/apps/web/src/pages/RoutineEditor.tsx
Claude d3689b88fc
feat(onboarding): show the onboarding cards and check-in names in the user's language
The onboarding cards, the closing line and the coach routine names were English server text
for every user. The server keeps the state and an English fallback; the web now words them
through Lingui, so there is one translation source.

- Choice cards: the web says the question, the option labels and the placeholder from the
  step and option ids, and keeps the server text for anything it does not know (quiet-hour
  ranges are already written in the user's locale). Another bot's focus card is recognized
  by its option ids. Mobile and agent history keep the server text.
- The "Got it. … are a good place to start." line is gone: the app cards speak for
  themselves, and with no cards nothing is said.
- The closing line becomes an intake_note block (note "done", nextCheckInAt, English text).
  The web words it with the first check-in in the user's clock; mobile blockText and
  blocksToAgentHistoryText read its text through their generic fallback.
- Routines carry coachKey. The routine list and editor show a check-in under its label in
  the UI language until the user renames it; saving an untouched label keeps the stored
  name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:51:59 +00:00

666 lines
20 KiB
TypeScript

import { t } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import type { Routine } from "@rakazo/contracts";
import {
type CronFreq,
type CronPreset,
cronFromPreset,
defaultCronPreset,
formatCron,
isOneShotRoutineCrons,
presetFromCron,
} from "@rakazo/core";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Input,
Textarea,
} from "@rakazo/ui-web";
import { ChevronLeft, Clock, GitBranch, Globe, MessageSquare, Pause, Plus, X } from "lucide-react";
import { useId } from "react";
import { routineName } from "../lib/routine-name";
import { RoutineSchedule } from "./RoutineSchedule";
function toDatetimeLocalValue(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function defaultArmRunAtLocal(): string {
return toDatetimeLocalValue(new Date(Date.now() + 60 * 60 * 1000));
}
export function routineNeedsOneShotArm(
routine: Pick<Routine, "nextRunAt" | "lastRunAt">,
crons: string[],
) {
return isOneShotRoutineCrons(crons) && !routine.nextRunAt && !routine.lastRunAt;
}
const SCHEDULE_PRESETS: CronFreq[] = [
"Every hour",
"Every day",
"Weekdays",
"Every week",
"Every month",
"Interval",
"Advanced",
];
const COMING_SOON = [
{ id: "teams", label: () => t`Teams message` },
{ id: "linear", label: () => t`Linear issue` },
{ id: "sentry", label: () => t`Sentry alert` },
{ id: "pagerduty", label: () => t`PagerDuty incident` },
] as const;
export type RoutineDraftState = {
name: string;
prompt: string;
schedules: CronPreset[];
webhookEnabled: boolean;
githubEnabled: boolean;
messageProvider: string | null;
active: boolean;
runAtLocal: string;
};
export function emptyRoutineDraft(): RoutineDraftState {
return {
name: "",
prompt: "",
schedules: [],
webhookEnabled: false,
githubEnabled: false,
messageProvider: null,
active: true,
runAtLocal: "",
};
}
export function draftFromRoutine(routine: Routine): RoutineDraftState {
return {
name: routineName(routine),
prompt: routine.prompt,
schedules: routine.crons.map(presetFromCron),
webhookEnabled: routine.webhookEnabled,
githubEnabled: routine.githubEnabled,
messageProvider: routine.messageProvider,
active: routine.active,
runAtLocal: routineNeedsOneShotArm(routine, routine.crons) ? defaultArmRunAtLocal() : "",
};
}
export function routineTriggerSummary(routine: Routine): string {
if (!routine.active) return t`Paused`;
const parts: string[] = [];
if (routine.webhookEnabled) parts.push(t`When a webhook fires`);
if (routine.githubEnabled) parts.push(t`Git event`);
if (routine.messageProvider === "slack") parts.push(t`Slack message`);
else if (routine.messageProvider === "teams") parts.push(t`Teams message`);
else if (routine.messageProvider) parts.push(t`Message event`);
for (const cron of routine.crons) parts.push(formatCron(cron));
return parts.length > 0 ? parts.join(" · ") : t`No trigger`;
}
export function RoutineListHeader({ onCreate }: { onCreate: () => void }) {
const { t } = useLingui();
return (
<div className="mt-[30px] mb-3 flex items-center justify-between gap-3">
<div className="text-sm text-muted-foreground">
<Trans>Routines</Trans>
</div>
<Button
variant="secondary"
size="icon-sm"
data-testid="routine-create-button"
aria-label={t`Create Routine`}
title={t`Create Routine`}
onClick={onCreate}
>
<Plus strokeWidth={1.9} />
</Button>
</div>
);
}
export function RoutineListRow({
routine,
running,
onOpen,
onStop,
}: {
routine: Routine;
running: boolean;
onOpen: () => void;
onStop: () => void;
}) {
return (
<div className="flex w-full items-center gap-2 rounded-xl px-2.5 py-2.5 hover:bg-accent">
<button
type="button"
onClick={onOpen}
className="flex min-w-0 flex-1 items-center gap-3 text-start"
>
<span className="grid h-5 w-5 place-items-center">
{routine.active ? (
<Clock strokeWidth={1.6} className="text-success size-icon-md" aria-hidden />
) : (
<Pause className="text-muted-foreground size-icon-md" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14.5px] font-medium text-foreground" dir="auto">
{routineName(routine)}
</span>
<span className="block truncate text-ui-body-sm text-muted-foreground/80">
{routineTriggerSummary(routine)}
</span>
</span>
</button>
{running ? (
<Button
size="xs"
onClick={onStop}
className="shrink-0 rounded-full bg-warning/15 text-warning hover:bg-warning/25"
>
<Trans>Running · Stop</Trans>
</Button>
) : null}
</div>
);
}
export function RoutineEditor({
draft,
onChange,
editing,
timezone,
webhook,
githubPath,
messageProviders,
saving,
running,
error,
onBack,
onClose,
onSave,
onTestRun,
onDelete,
onEnsureWebhook,
}: {
draft: RoutineDraftState;
onChange: (next: RoutineDraftState) => void;
editing: Routine | null;
timezone: string;
webhook: { path: string; secret: string | null; configured: boolean };
githubPath: string;
messageProviders: string[];
saving: boolean;
running: boolean;
error: string | null;
onBack: () => void;
onClose: () => void;
onSave: () => void;
onTestRun: () => void;
onDelete: () => void;
onEnsureWebhook: () => Promise<void>;
}) {
const { t } = useLingui();
const fieldId = useId();
const slackAvailable = messageProviders.includes("slack");
const slackDisabledReasonId = `${fieldId}-slack-disabled-reason`;
const hasTriggers =
draft.schedules.length > 0 ||
draft.webhookEnabled ||
draft.githubEnabled ||
Boolean(draft.messageProvider);
const canTest = Boolean(editing) && !saving && !running;
const needsOneShotArm =
editing != null && routineNeedsOneShotArm(editing, draft.schedules.map(cronFromPreset));
function addSchedule(freq: CronFreq) {
const base = defaultCronPreset();
const next: CronPreset =
freq === "Advanced" ? { ...base, freq, cron: cronFromPreset(base) } : { ...base, freq };
onChange({ ...draft, schedules: [...draft.schedules, next] });
}
async function addWebhook() {
onChange({ ...draft, webhookEnabled: true });
if (!webhook.configured) {
await onEnsureWebhook().catch(() => undefined);
}
}
async function addGithub() {
onChange({ ...draft, githubEnabled: true });
if (!webhook.configured) {
await onEnsureWebhook().catch(() => undefined);
}
}
function addMessageProvider(provider: string) {
onChange({ ...draft, messageProvider: provider });
}
return (
<div>
<div className="mb-5 flex items-center justify-between">
<Button
variant="ghost"
size="icon-sm"
onClick={onBack}
className="text-muted-foreground"
aria-label={t`Back`}
>
<ChevronLeft />
</Button>
<div className="text-ui-body font-medium text-foreground">
<Trans>Routine</Trans>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
className="text-muted-foreground"
aria-label={t`Close`}
>
<X />
</Button>
</div>
<div className="mb-5 flex items-center justify-between gap-3">
<label className="flex items-center gap-2.5 text-sm text-foreground/75">
<button
type="button"
role="switch"
aria-checked={draft.active}
onClick={() => onChange({ ...draft, active: !draft.active })}
className={`relative h-[22px] w-[40px] rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${
draft.active ? "bg-primary" : "bg-input"
}`}
>
<span
className={`absolute top-[2px] left-0 h-[18px] w-[18px] rounded-full bg-background transition-transform ${
draft.active
? "translate-x-[20px] dark:bg-primary-foreground"
: "translate-x-[2px] dark:bg-foreground"
}`}
/>
</button>
<Trans>Active</Trans>
</label>
<div className="flex items-center gap-2">
<Button variant="secondary" disabled={saving || running} onClick={onDelete}>
<Trans>Delete</Trans>
</Button>
<Button variant="secondary" disabled={!canTest} onClick={onTestRun}>
{running ? t`Running…` : t`Test run`}
</Button>
</div>
</div>
<label htmlFor={`${fieldId}-name`} className="block text-sm text-muted-foreground">
<Trans>Name</Trans>
<Input
id={`${fieldId}-name`}
value={draft.name}
placeholder={t`Name this routine`}
onChange={(e) => onChange({ ...draft, name: e.target.value })}
className="mt-2"
/>
</label>
<label htmlFor={`${fieldId}-prompt`} className="mt-5 block text-sm text-muted-foreground">
<Trans>Instruction</Trans>
<Textarea
id={`${fieldId}-prompt`}
value={draft.prompt}
placeholder={t`What should this routine do each time it runs?`}
onChange={(e) => onChange({ ...draft, prompt: e.target.value })}
rows={4}
className="mt-2"
/>
</label>
<div className="mt-5 text-sm text-muted-foreground">
<div className="flex items-baseline gap-2">
<Trans>When to run</Trans>
<span className="text-xs text-muted-foreground/70">{timezone}</span>
</div>
<div className="mt-2 space-y-2">
{draft.schedules.map((preset, index) => (
<div key={index} className="relative">
<RoutineSchedule
value={preset}
onChange={(next) =>
onChange({
...draft,
schedules: draft.schedules.map((item, i) => (i === index ? next : item)),
})
}
/>
<Button
variant="ghost"
size="icon-xs"
aria-label={t`Remove schedule`}
onClick={() =>
onChange({
...draft,
schedules: draft.schedules.filter((_, i) => i !== index),
})
}
className="absolute top-2 right-2 text-muted-foreground"
>
<X />
</Button>
</div>
))}
{draft.webhookEnabled ? (
<InboundTriggerCard
kind="webhook"
saved={Boolean(editing)}
path={webhook.path}
secret={webhook.secret}
configured={webhook.configured}
onRemove={() => onChange({ ...draft, webhookEnabled: false })}
onRotate={() => void onEnsureWebhook()}
/>
) : null}
{draft.githubEnabled ? (
<InboundTriggerCard
kind="github"
saved={Boolean(editing)}
path={githubPath}
secret={webhook.secret}
configured={webhook.configured}
onRemove={() => onChange({ ...draft, githubEnabled: false })}
onRotate={() => void onEnsureWebhook()}
/>
) : null}
{draft.messageProvider ? (
<MessageTriggerCard
provider={draft.messageProvider}
onRemove={() => onChange({ ...draft, messageProvider: null })}
/>
) : null}
{needsOneShotArm ? (
<label htmlFor={`${fieldId}-run-at`} className="block text-sm text-muted-foreground">
<Trans>Run at</Trans>
<Input
id={`${fieldId}-run-at`}
type="datetime-local"
value={draft.runAtLocal}
onChange={(e) => onChange({ ...draft, runAtLocal: e.target.value })}
aria-label={t`Run at`}
className="mt-2"
/>
</label>
) : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" className="mt-2 h-auto w-full rounded-xl py-3" />}
>
<Plus />
<Trans>Add trigger</Trans>
</DropdownMenuTrigger>
<DropdownMenuContent side="top">
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Clock />
<Trans>On a schedule</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[170px]">
{SCHEDULE_PRESETS.map((freq) => (
<DropdownMenuItem key={freq} onClick={() => addSchedule(freq)}>
{schedulePresetLabel(freq)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<span className="block" title={slackAvailable ? undefined : t`Slack not enabled`}>
<DropdownMenuItem
disabled={draft.messageProvider === "slack" || !slackAvailable}
aria-describedby={slackAvailable ? undefined : slackDisabledReasonId}
onClick={() => addMessageProvider("slack")}
>
<MessageSquare />
<Trans>Slack message</Trans>
</DropdownMenuItem>
{!slackAvailable ? (
<span id={slackDisabledReasonId} className="sr-only">
<Trans>Slack not enabled</Trans>
</span>
) : null}
</span>
{COMING_SOON.map((item) => (
<span
key={item.id}
className="block"
title={item.id === "teams" ? t`Teams not enabled` : t`Coming soon`}
>
<DropdownMenuItem disabled>
<span
aria-hidden
className="inline-block size-3.5 rounded-[4px]"
style={{ background: comingSoonColor(item.id), opacity: 0.55 }}
/>
{item.label()}
</DropdownMenuItem>
</span>
))}
<DropdownMenuItem disabled={draft.githubEnabled} onClick={() => void addGithub()}>
<GitBranch />
<Trans>Git event</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={draft.webhookEnabled} onClick={() => void addWebhook()}>
<Globe />
<Trans>Webhook</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!hasTriggers ? (
<p className="mt-2 text-xs text-muted-foreground/70">
<Trans>Add a schedule, webhook, GitHub, or message trigger to run this routine.</Trans>
</p>
) : null}
</div>
<div className="mt-5">
<Button disabled={saving || running || !hasTriggers} onClick={onSave}>
{saving ? t`Saving…` : t`Save`}
</Button>
</div>
{error ? (
<p role="alert" className="mt-3 text-ui-body-sm text-destructive">
{error}
</p>
) : null}
<div className="mt-8 text-sm text-muted-foreground">
<Trans>Run history</Trans>
<p className="mt-2 text-ui-body text-muted-foreground/80">
<Trans>No runs yet</Trans>
</p>
</div>
</div>
);
}
function MessageTriggerCard({ provider, onRemove }: { provider: string; onRemove: () => void }) {
const { t } = useLingui();
const label =
provider === "slack"
? t`Slack message`
: provider === "teams"
? t`Teams message`
: t`Message event`;
return (
<div className="rounded-xl border border-border p-3">
<div className="flex items-center gap-2.5 px-0.5">
<MessageSquare
strokeWidth={1.6}
className="text-muted-foreground size-icon-md"
aria-hidden
/>
<span className="flex-1 text-[14.5px] text-foreground">{label}</span>
<Button
variant="ghost"
size="icon-xs"
aria-label={t`Remove message trigger`}
onClick={onRemove}
className="text-muted-foreground"
>
<X />
</Button>
</div>
<p className="mt-2.5 text-ui-body text-muted-foreground/70">
<Trans>Runs when this bot receives a verified message from this provider.</Trans>
</p>
</div>
);
}
function InboundTriggerCard({
kind,
saved,
path,
secret,
configured,
onRemove,
onRotate,
}: {
kind: "webhook" | "github";
saved: boolean;
path: string;
secret: string | null;
configured: boolean;
onRemove: () => void;
onRotate: () => void;
}) {
const { t } = useLingui();
const pending = !saved;
const placeholder = t`Available after the routine is saved`;
// GitHub delivery URL and signature header are fixed by bot id / protocol, so show
// them before save. The shared secret still needs a saved routine to mint.
const postValue = kind === "github" || !pending ? path : placeholder;
const keyValue = pending
? placeholder
: (secret ?? (configured ? t`Saved. Rotate to reveal.` : placeholder));
const headerValue =
kind === "github"
? "X-Hub-Signature-256: sha256=…"
: pending
? placeholder
: secret
? `Authorization: Bearer ${secret}`
: configured
? "Authorization: Bearer …"
: placeholder;
const cellClass =
"break-all rounded-lg bg-muted px-2.5 py-1.5 font-mono text-xs text-foreground/75";
return (
<div className="rounded-xl border border-border p-3">
<div className="flex items-center gap-2.5 px-0.5">
{kind === "github" ? (
<GitBranch strokeWidth={1.6} className="text-muted-foreground size-icon-md" aria-hidden />
) : (
<Globe strokeWidth={1.6} className="text-muted-foreground size-icon-md" aria-hidden />
)}
<span className="flex-1 text-[14.5px] text-foreground">
{kind === "github" ? <Trans>Git event</Trans> : <Trans>When a webhook fires</Trans>}
</span>
<Button
variant="ghost"
size="icon-xs"
aria-label={kind === "github" ? t`Remove Git event` : t`Remove webhook`}
onClick={onRemove}
className="text-muted-foreground"
>
<X />
</Button>
</div>
<div className="mt-2.5 space-y-2.5 text-ui-body">
<div className="block text-muted-foreground/70">
<Trans>POST to</Trans>
<div className={`mt-1 ${cellClass}`}>{postValue}</div>
</div>
<div className="flex items-center gap-2 text-muted-foreground/70">
<span className="shrink-0">
<Trans>key</Trans>
</span>
<div className={`min-w-0 flex-1 ${cellClass}`}>{keyValue}</div>
</div>
<div className="block text-muted-foreground/70">
<Trans>header</Trans>
<div className={`mt-1 ${cellClass}`}>{headerValue}</div>
</div>
{saved && configured && !secret ? (
<Button
variant="link"
size="xs"
onClick={onRotate}
className="px-0 text-muted-foreground"
>
<Trans>Rotate key</Trans>
</Button>
) : null}
</div>
</div>
);
}
function schedulePresetLabel(freq: CronFreq): string {
switch (freq) {
case "Every hour":
return t`Every hour`;
case "Every day":
return t`Every day`;
case "Weekdays":
return t`Weekdays`;
case "Every week":
return t`Every week`;
case "Every month":
return t`Every month`;
case "Interval":
return t`Interval`;
case "Advanced":
return t`Advanced...`;
default:
return freq;
}
}
function comingSoonColor(id: string): string {
switch (id) {
case "teams":
return "#6264A7";
case "linear":
return "#5E6AD2";
case "sentry":
return "#A467FD";
default:
return "#06AC38";
}
}