evOo-local/apps/web/src/pages/RoutineEditor.tsx
Claude 6f2eda521f
Read the web app at 16px base size on phones
Text sizes now follow a --font-scale variable that is 1 on wider screens and
8/7 below 768px, so the 14px base text reads at 16px on phones (Safari stops
zooming into fields at that size) and every other size grows by the same
ratio. One-off pixel sizes in the shell move onto the shared text tokens so
they scale too. The stage subtitle now truncates inside the screen instead
of spilling past both edges.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFEDgbyJm12ExL3Kn4Dk8B
2026-10-05 08:45:07 +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-ui-body-sm 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-ui-body-sm 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-ui-body-sm 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";
}
}