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
666 lines
20 KiB
TypeScript
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";
|
|
}
|
|
}
|