mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-09 03:18:04 +00:00
feat(web): add Company Brain automation settings
Add recurring automation templates, creation and editing, Slack destination and default-channel controls, DST-aware GMT-labelled timezone selection, ownership and Slack-origin labels, and explicit loading and error states.
This commit is contained in:
parent
566be20898
commit
e2aef5e482
2 changed files with 928 additions and 335 deletions
File diff suppressed because it is too large
Load diff
282
apps/web/components/settings/company-brain-automations/domain.ts
Normal file
282
apps/web/components/settings/company-brain-automations/domain.ts
Normal file
|
|
@ -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<string>,
|
||||||
|
): 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))
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue