diff --git a/apps/web/components/settings/company-brain-automations.tsx b/apps/web/components/settings/company-brain-automations.tsx index 74f8e7b0..888d4533 100644 --- a/apps/web/components/settings/company-brain-automations.tsx +++ b/apps/web/components/settings/company-brain-automations.tsx @@ -8,6 +8,7 @@ import { CalendarClock, ChevronDown, ChevronUp, + ExternalLink, FileText, GitPullRequest, Info, @@ -19,7 +20,7 @@ import { Radar, Trash2, } from "lucide-react" -import { useRef, useState } from "react" +import { useMemo, useRef, useState } from "react" import { toast } from "sonner" import { Select, @@ -36,251 +37,45 @@ import { } from "@ui/components/tooltip" import { useHasCompanyBrain } from "@/hooks/use-company-brain" import { dmSans125ClassName } from "@/lib/fonts" +import { + type Automation, + type AutomationCatalogTemplate, + type AutomationChannel, + type AutomationDraft, + type AutomationSettings, + type Frequency, + type TimezoneOption, + automationToDraft, + catalogTemplateToDraft, + DEFAULT_AUTOMATION_PROMPT, + emptyAutomationDraft, + fromLocalCron, + sortCatalogTemplates, + timezoneDisplayLabel, + timezoneOptions, + toLocalCron, + WEEKDAYS, +} from "./company-brain-automations/domain" const BACKEND = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" const BASE = `${BACKEND}/brain/automations` -type Automation = { - id: string - enabled: boolean - title: string - channelId: string - deliverTo: "channel" | "dm" - prompt: string - cron: string - timezone: string | null - createdBy: string | null -} -type Channel = { id: string; name: string; isPrivate: boolean } -type Frequency = "daily" | "weekly" - -const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] - -const DEFAULT_PROMPT = - "Summarize what's happened recently across the connected tools and channels: open items, unanswered questions, decisions, and anything the team should know. Keep it a short, scannable recap." - -// Local day/time -> UTC cron; the Date roundtrip carries any day rollover. -function toUtcCron( - time: string, - frequency: Frequency, - weekday: number, -): string | null { - const [hh, mm] = time.split(":").map(Number) - if ( - hh === undefined || - mm === undefined || - Number.isNaN(hh) || - Number.isNaN(mm) - ) - return null - const d = new Date() - d.setHours(hh, mm, 0, 0) - if (frequency === "weekly") - d.setDate(d.getDate() + ((weekday - d.getDay() + 7) % 7)) - const m = d.getUTCMinutes() - const h = d.getUTCHours() - return frequency === "daily" - ? `${m} ${h} * * *` - : `${m} ${h} * * ${d.getUTCDay()}` +function iconForTemplate(template: AutomationCatalogTemplate): LucideIcon { + if (template.id === "unanswered") return MessageCircleQuestion + if (template.id === "prs-review") return GitPullRequest + if (template.id === "issue-triage") return ListTodo + if (template.id === "customer-signal") return LifeBuoy + if (template.id === "competitor-check") return Radar + if (template.id === "release-notes") return FileText + return CalendarClock } -function fromUtcCron( - cron: string, -): { frequency: Frequency; weekday: number; time: string } | null { - const parts = cron.trim().split(/\s+/) - if (parts.length !== 5) return null - const [min, hr, , , dow] = parts - const mm = Number(min) - const hh = Number(hr) - if (Number.isNaN(mm) || Number.isNaN(hh)) return null - const d = new Date() - d.setUTCHours(hh, mm, 0, 0) - const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` - if (dow === "*") return { frequency: "daily", weekday: 1, time } - const targetDow = Number(dow) - if (Number.isNaN(targetDow)) return null - d.setUTCDate(d.getUTCDate() + ((targetDow - d.getUTCDay() + 7) % 7)) - return { frequency: "weekly", weekday: d.getDay(), time } -} - -type Draft = { - title: string - channelId: string - deliverTo: "channel" | "dm" - prompt: string - frequency: Frequency - weekday: number - time: string - enabled: boolean -} - -function toDraft(a: Automation): Draft { - const parsed = fromUtcCron(a.cron) - return { - title: a.title, - channelId: a.channelId, - deliverTo: a.deliverTo === "dm" ? "dm" : "channel", - prompt: a.prompt, - frequency: parsed?.frequency ?? "daily", - weekday: parsed?.weekday ?? 1, - time: parsed?.time ?? "09:00", - enabled: a.enabled, +function cadenceLabel(template: AutomationCatalogTemplate): string { + if (template.cadence.frequency === "weekly") { + return `Weekly · ${WEEKDAYS[template.cadence.weekday ?? 1] ?? "Mon"} ${template.cadence.time}` } -} - -const emptyDraft = (): Draft => ({ - title: "", - channelId: "", - deliverTo: "channel", - prompt: DEFAULT_PROMPT, - frequency: "daily", - weekday: 1, - time: "09:00", - enabled: true, -}) - -type Category = "team" | "engineering" | "support" | "product" - -type Preset = { - id: string - label: string - description: string - icon: LucideIcon - category: Category - requiresApps?: string[] - prompt: string - frequency: Frequency - weekday?: number - time: string -} - -const PRESETS: Preset[] = [ - { - id: "standup", - category: "team", - label: "Morning checkup", - description: - "Shipped work, decisions, blockers & open questions from the last 24h.", - icon: CalendarClock, - prompt: - "Give a short standup for this channel: what happened in the last 24 hours across our connected tools and this channel — work shipped, decisions made, blockers, and open questions. Keep it tight and scannable.", - frequency: "daily", - time: "09:00", - }, - { - id: "weekly-recap", - category: "team", - label: "Company progress", - description: - "Decisions, shipped work & unresolved threads from the past week.", - icon: CalendarClock, - prompt: - "Weekly recap for the team: decisions made, work shipped, and unresolved threads across our connected tools and channels over the past 7 days.", - frequency: "weekly", - weekday: 1, - time: "09:00", - }, - { - id: "unanswered", - category: "team", - label: "Unanswered questions", - description: "Questions in this channel from the last 24h with no reply.", - icon: MessageCircleQuestion, - prompt: - "Surface questions asked in this channel in the last 24 hours that haven't gotten a reply yet, so nothing slips through.", - frequency: "daily", - time: "16:00", - }, - { - id: "prs-review", - category: "engineering", - label: "PRs awaiting review", - description: "Open PRs waiting on review; flags stale ones.", - icon: GitPullRequest, - requiresApps: ["github"], - prompt: - "List open pull requests awaiting review. Flag any with no activity for 2+ days. Group by repository.", - frequency: "daily", - time: "09:30", - }, - { - id: "issue-triage", - category: "engineering", - label: "Issue triage", - description: "New or unassigned issues that need a response.", - icon: ListTodo, - requiresApps: ["github", "linear"], - prompt: - "Summarize new or unassigned issues from the last 24 hours that need triage or a response.", - frequency: "daily", - time: "09:00", - }, - { - id: "customer-signal", - category: "support", - label: "Customer signal", - description: "Recent customer issues & feedback and their status.", - icon: LifeBuoy, - requiresApps: ["plain", "linear"], - prompt: - "Recap customer issues and feedback raised recently across our tools and channels, with their current status.", - frequency: "daily", - time: "09:00", - }, - { - id: "competitor-check", - category: "product", - label: "Competitor check", - description: "What competitors shipped, announced, or changed this week.", - icon: Radar, - prompt: - "Check what our competitors shipped, announced, or changed recently — launches, pricing changes, and anything the team should react to.", - frequency: "weekly", - weekday: 1, - time: "09:00", - }, - { - id: "release-notes", - category: "product", - label: "Release notes draft", - description: "Draft notes from PRs merged since the last digest.", - icon: FileText, - requiresApps: ["github"], - prompt: - "Draft release notes from pull requests merged since the last digest, grouped into features, fixes, and chores.", - frequency: "weekly", - weekday: 5, - time: "16:00", - }, -] - -function presetToDraft(p: Preset): Draft { - return { - title: p.label, - channelId: "", - deliverTo: "channel", - prompt: p.prompt, - frequency: p.frequency, - weekday: p.weekday ?? 1, - time: p.time, - enabled: true, - } -} - -// Connected-app presets first, universal next, unconnected-app presets last. -function sortPresets(connected: Set): Preset[] { - const rank = (p: Preset) => { - if (!p.requiresApps) return 1 - return p.requiresApps.some((a) => connected.has(a)) ? 0 : 2 - } - return [...PRESETS].sort((a, b) => rank(a) - rank(b)) -} - -function cadenceLabel(p: Preset): string { - if (p.frequency === "weekly") - return `Weekly · ${WEEKDAYS[p.weekday ?? 1] ?? "Mon"} ${p.time}` - return `Daily · ${p.time}` + return `Daily · ${template.cadence.time}` } const controlClass = cn( @@ -295,42 +90,208 @@ const selectContentClass = cn( const selectItemClass = "cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white" const DM_VALUE = "__dm__" +const CHANNEL_STATUS_VALUE = "__channel_status__" + +async function apiError(response: Response, fallback: string): Promise { + const body = (await response.json().catch(() => ({}))) as { error?: string } + return new Error(body.error ?? fallback) +} + +type AutomationChannelsResponse = { + connected: boolean + channels: AutomationChannel[] + defaultAutomationChannel: string | null +} + +async function fetchAutomationChannels( + forceRefresh = false, +): Promise { + const res = await fetch( + `${BASE}/channels${forceRefresh ? "?refresh=1" : ""}`, + { + credentials: "include", + }, + ) + if (!res.ok) throw await apiError(res, "Couldn't load Slack channels.") + const body = (await res.json()) as { + connected?: boolean + channels?: AutomationChannel[] + defaultAutomationChannel?: string | null + } + return { + connected: body.connected !== false, + channels: body.channels ?? [], + defaultAutomationChannel: + typeof body.defaultAutomationChannel === "string" + ? body.defaultAutomationChannel + : null, + } +} + +type ChannelLoadState = { + pending: boolean + error: string | null + connected: boolean | null + retry: () => void +} + +function OwnerChip({ label }: { label?: string }) { + if (!label) return null + return ( + + {label} + + ) +} + +function SlackSourceLink({ + sourceThreadUrl, +}: { + sourceThreadUrl?: string | null +}) { + if (!sourceThreadUrl?.startsWith("https://")) return null + return ( + + Slack thread + + + ) +} + +function ChannelLoadHint({ + state, + hasChannels, +}: { + state: ChannelLoadState + hasChannels: boolean +}) { + if ( + !state.pending && + !state.error && + state.connected !== false && + hasChannels + ) { + return null + } + const message = state.pending + ? "Loading Slack channels…" + : state.error + ? state.error + : state.connected === false + ? "Connect Slack to deliver automations to a channel." + : "Company Brain isn't in any channels yet. Add it to a channel, then refresh." + return ( + + {message} + {!state.pending && state.connected !== false ? ( + + ) : null} + + ) +} + +function QueryErrorNotice({ + message, + onRetry, +}: { + message: string + onRetry: () => void +}) { + return ( +
+ {message} + +
+ ) +} function AutomationCard({ initial, id, channels, + channelLoad, + timezoneChoices: supportedTimezoneChoices, ownerLabel, + sourceThreadUrl, onDone, + onChanged, onCancelNew, onCollapse, }: { - initial: Draft + initial: AutomationDraft id: string | null - channels: Channel[] + channels: AutomationChannel[] + channelLoad: ChannelLoadState + timezoneChoices: TimezoneOption[] ownerLabel?: string + sourceThreadUrl?: string | null onDone: () => void + onChanged: () => void onCancelNew?: () => void onCollapse?: () => void }) { - const [draft, setDraft] = useState(initial) - const set = (k: K, v: Draft[K]) => - setDraft((d) => ({ ...d, [k]: v })) + const [draft, setDraft] = useState(initial) + const timezoneChoices = useMemo(() => { + if ( + supportedTimezoneChoices.some((option) => option.value === draft.timezone) + ) { + return supportedTimezoneChoices + } + return timezoneOptions(new Date(), [ + ...supportedTimezoneChoices.map((option) => option.value), + draft.timezone, + ]) + }, [draft.timezone, supportedTimezoneChoices]) + const set = ( + k: K, + v: AutomationDraft[K], + ) => { + const changesSchedule = k === "frequency" || k === "weekday" || k === "time" + setDraft((draft) => ({ + ...draft, + [k]: v, + ...(changesSchedule ? { rawCron: null } : {}), + ...(k !== "frequency" && draft.frequency === "advanced" && changesSchedule + ? { frequency: "daily" as const } + : {}), + })) + } const body = () => { if (!draft.title.trim()) throw new Error("Give the automation a name.") if (draft.deliverTo === "channel" && !draft.channelId) throw new Error("Pick a channel to post to.") - const cron = toUtcCron(draft.time, draft.frequency, draft.weekday) + const cron = + draft.rawCron ?? toLocalCron(draft.time, draft.frequency, draft.weekday) if (!cron) throw new Error("Pick a valid time.") + if (!draft.timezone.trim()) throw new Error("Enter an IANA timezone.") return { title: draft.title.trim(), deliverTo: draft.deliverTo, channelId: draft.deliverTo === "dm" ? null : draft.channelId, - prompt: draft.prompt.trim() || DEFAULT_PROMPT, + prompt: draft.prompt.trim() || DEFAULT_AUTOMATION_PROMPT, cron, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + timezone: draft.timezone.trim(), enabled: draft.enabled, + catalogId: draft.catalogId, } } @@ -369,11 +330,25 @@ function AutomationCard({ ok?: boolean reason?: string error?: string + outcome?: "deliver" | "skip" | "complete" } if (!res.ok || b.ok === false) throw new Error(b.reason ?? b.error ?? "Couldn't run.") + return b.outcome ?? "deliver" + }, + onSuccess: (outcome) => { + toast.success( + outcome === "complete" + ? "Condition met — automation completed without posting." + : outcome === "skip" + ? "Automation checked and skipped this occurrence." + : "Automation triggered — check the channel.", + ) + if (outcome === "complete") { + setDraft((current) => ({ ...current, enabled: false })) + } + onChanged() }, - onSuccess: () => toast.success("Automation triggered — check the channel."), onError: (err) => toast.error(err instanceof Error ? err.message : "Couldn't run."), }) @@ -414,16 +389,8 @@ function AutomationCard({ value={draft.title} onChange={(e) => set("title", e.target.value)} /> - {ownerLabel ? ( - - {ownerLabel} - - ) : null} + + {onCollapse ? ( - + + + + + + + ) : null} +

+ ) : null} + + +
+
+

+ Your automations +

+ +
{automations.map((a) => openId === a.id ? ( { setOpenId(null) refresh() }} + onChanged={refresh} onCollapse={() => setOpenId(null)} /> ) : ( @@ -936,11 +1211,7 @@ export default function CompanyBrainAutomations() { key={a.id} automation={a} channels={channels} - ownerLabel={ - a.createdBy && a.createdBy !== user?.id - ? nameFor(a.createdBy) - : undefined - } + ownerLabel={ownerLabelFor(a.createdBy)} onExpand={() => setOpenId(a.id)} onChanged={refresh} /> @@ -953,45 +1224,85 @@ export default function CompanyBrainAutomations() { id={null} initial={draft} channels={channels} + channelLoad={channelLoad} + timezoneChoices={supportedTimezoneChoices} onDone={() => { removeDraft(key) refresh() }} + onChanged={refresh} onCancelNew={() => removeDraft(key)} /> ))} - - {hasList ? ( -

- Templates + {listQuery.isPending ? ( +

Loading automations…

+ ) : null} + {listError ? ( + { + void listQuery.refetch() + }} + /> + ) : null} + {listQuery.data !== undefined && + !listError && + !automations.length && + !drafts.length ? ( +

+ No automations yet. Start with a template or create your own.

) : null} +
+ +
+

+ Templates +

- {availablePresets.map((p) => ( + {availableTemplates.map((template) => ( addDraft(presetToDraft(p))} + key={template.id} + preset={template} + onPick={() => + addDraft(catalogTemplateToDraft(template, defaultChannelId)) + } /> ))} -
+ {catalogQuery.isPending || listQuery.isPending ? ( +

Loading templates…

+ ) : null} + {catalogError ? ( + { + void catalogQuery.refetch() + }} + /> + ) : null} + {appsError && !catalogError ? ( + { + void appsQuery.refetch() + }} + /> + ) : null} + {catalogQuery.data !== undefined && + listQuery.data !== undefined && + !catalogError && + !listError && + !availableTemplates.length ? ( +

+ All templates are already in your automations. +

+ ) : null}
) diff --git a/apps/web/components/settings/company-brain-automations/domain.ts b/apps/web/components/settings/company-brain-automations/domain.ts new file mode 100644 index 00000000..490684cc --- /dev/null +++ b/apps/web/components/settings/company-brain-automations/domain.ts @@ -0,0 +1,282 @@ +export type AutomationOrigin = "web" | "slack" + +export type Automation = { + id: string + enabled: boolean + title: string + channelId: string + deliverTo: "channel" | "dm" + prompt: string + cron: string + timezone: string | null + createdBy: string | null + origin: AutomationOrigin + sourceThreadUrl: string | null + catalogId: string | null +} + +export type AutomationChannel = { + id: string + name: string + isPrivate: boolean +} + +export type AutomationCatalogTemplate = { + id: string + label: string + description: string + category: "team" | "engineering" | "support" | "product" + requiresApps: string[] + prompt: string + cadence: { + frequency: "daily" | "weekly" + weekday?: number + time: string + } +} + +export type AutomationSettings = { + defaultAutomationChannel: string | null + canEdit: boolean +} + +export type Frequency = "advanced" | "daily" | "weekdays" | "weekly" + +export type AutomationDraft = { + title: string + channelId: string + deliverTo: "channel" | "dm" + prompt: string + frequency: Frequency + weekday: number + time: string + timezone: string + enabled: boolean + catalogId: string | null + /** Preserved when a Slack-created cron is outside the simple web editor. */ + rawCron: string | null +} + +export const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + +export const DEFAULT_AUTOMATION_PROMPT = + "Summarize what's happened recently across the connected tools and channels: open items, unanswered questions, decisions, and anything the team should know. Keep it a short, scannable recap." + +export type TimezoneOption = { + value: string + label: string + offsetLabel: string + offsetMinutes: number +} + +const FALLBACK_TIMEZONES = [ + "UTC", + "America/Los_Angeles", + "America/Denver", + "America/Chicago", + "America/New_York", + "Europe/London", + "Europe/Paris", + "Asia/Kolkata", + "Asia/Singapore", + "Asia/Tokyo", + "Australia/Sydney", +] + +function supportedTimezones(): string[] { + const intl = Intl as typeof Intl & { + supportedValuesOf?: (key: "timeZone") => string[] + } + return intl.supportedValuesOf?.("timeZone") ?? FALLBACK_TIMEZONES +} + +function offsetParts( + timezone: string, + at: Date, +): { label: string; minutes: number } | null { + try { + const raw = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + timeZoneName: "longOffset", + }).formatToParts(at) + const name = raw.find((part) => part.type === "timeZoneName")?.value + if (!name || name === "GMT" || name === "UTC") { + return { label: "GMT+00:00", minutes: 0 } + } + const match = name.match(/(?:GMT|UTC)([+-])(\d{1,2})(?::?(\d{2}))?/i) + if (!match) return null + const sign = match[1] === "-" ? -1 : 1 + const hours = Number(match[2]) + const minutes = Number(match[3] ?? "0") + return { + label: `GMT${sign < 0 ? "-" : "+"}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`, + minutes: sign * (hours * 60 + minutes), + } + } catch { + return null + } +} + +function friendlyTimezone(timezone: string): string { + return timezone + .split("/") + .map((part) => part.replaceAll("_", " ")) + .join(" / ") +} + +/** GMT-labelled IANA zones. Persisting `value` keeps local schedules DST-aware. */ +export function timezoneOptions( + at = new Date(), + timezones: readonly string[] = supportedTimezones(), +): TimezoneOption[] { + const values = [...new Set(["UTC", ...timezones])] + return values + .map((value): TimezoneOption | null => { + const offset = offsetParts(value, at) + if (!offset) return null + return { + value, + label: `${offset.label} · ${friendlyTimezone(value)}`, + offsetLabel: offset.label, + offsetMinutes: offset.minutes, + } + }) + .filter((option): option is TimezoneOption => option !== null) + .sort( + (a, b) => + a.offsetMinutes - b.offsetMinutes || a.value.localeCompare(b.value), + ) +} + +export function timezoneDisplayLabel( + timezone: string, + at = new Date(), +): string { + const offset = offsetParts(timezone, at) + return offset + ? `${offset.label} · ${friendlyTimezone(timezone)}` + : friendlyTimezone(timezone) +} + +export function browserTimezone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" +} + +/** Local wall-clock fields are stored directly; the backend evaluates them in `timezone`. */ +export function toLocalCron( + time: string, + frequency: Frequency, + weekday: number, +): string | null { + const [hourRaw, minuteRaw] = time.split(":") + const hour = Number(hourRaw) + const minute = Number(minuteRaw) + if ( + !Number.isInteger(hour) || + !Number.isInteger(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 + ) { + return null + } + if (frequency === "advanced") return null + if (frequency === "weekdays") return `${minute} ${hour} * * 1-5` + if (frequency === "weekly") { + if (!Number.isInteger(weekday) || weekday < 0 || weekday > 6) return null + return `${minute} ${hour} * * ${weekday}` + } + return `${minute} ${hour} * * *` +} + +/** Parse the server's local-time cron without applying the browser's UTC offset. */ +export function fromLocalCron( + cron: string, +): { frequency: Frequency; weekday: number; time: string } | null { + const parts = cron.trim().split(/\s+/) + if (parts.length !== 5) return null + const [minuteRaw, hourRaw, dayOfMonth, month, dayOfWeek] = parts + const minute = Number(minuteRaw) + const hour = Number(hourRaw) + if ( + !Number.isInteger(minute) || + !Number.isInteger(hour) || + minute < 0 || + minute > 59 || + hour < 0 || + hour > 23 || + dayOfMonth !== "*" || + month !== "*" + ) { + return null + } + const time = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}` + if (dayOfWeek === "*") return { frequency: "daily", weekday: 1, time } + if (dayOfWeek === "1-5" || dayOfWeek === "1,2,3,4,5") { + return { frequency: "weekdays", weekday: 1, time } + } + const weekday = Number(dayOfWeek === "7" ? "0" : dayOfWeek) + if (!Number.isInteger(weekday) || weekday < 0 || weekday > 6) return null + return { frequency: "weekly", weekday, time } +} + +export function automationToDraft(automation: Automation): AutomationDraft { + const parsed = fromLocalCron(automation.cron) + return { + title: automation.title, + channelId: automation.channelId, + deliverTo: automation.deliverTo === "dm" ? "dm" : "channel", + prompt: automation.prompt, + frequency: parsed?.frequency ?? "advanced", + weekday: parsed?.weekday ?? 1, + time: parsed?.time ?? "09:00", + timezone: automation.timezone || browserTimezone(), + enabled: automation.enabled, + catalogId: automation.catalogId, + rawCron: parsed ? null : automation.cron, + } +} + +export function emptyAutomationDraft(defaultChannelId = ""): AutomationDraft { + return { + title: "", + channelId: defaultChannelId, + deliverTo: "channel", + prompt: DEFAULT_AUTOMATION_PROMPT, + frequency: "daily", + weekday: 1, + time: "09:00", + timezone: browserTimezone(), + enabled: true, + catalogId: null, + rawCron: null, + } +} + +export function catalogTemplateToDraft( + template: AutomationCatalogTemplate, + defaultChannelId = "", +): AutomationDraft { + return { + ...emptyAutomationDraft(defaultChannelId), + title: template.label, + prompt: template.prompt, + frequency: template.cadence.frequency, + weekday: template.cadence.weekday ?? 1, + time: template.cadence.time, + catalogId: template.id, + } +} + +/** Connected-app templates first, universal templates next, unavailable-app templates last. */ +export function sortCatalogTemplates( + templates: readonly AutomationCatalogTemplate[], + connected: ReadonlySet, +): AutomationCatalogTemplate[] { + const rank = (template: AutomationCatalogTemplate) => { + if (!template.requiresApps.length) return 1 + return template.requiresApps.some((app) => connected.has(app)) ? 0 : 2 + } + return [...templates].sort((a, b) => rank(a) - rank(b)) +}