/** * — schedule management UI. * * Job list with create/edit forms, execution log viewer, * and manual run triggers. REST-based (no Automerge client sync). */ interface JobData { id: string; name: string; description: string; enabled: boolean; cronExpression: string; cronHuman?: string; timezone: string; actionType: string; actionConfig: Record; lastRunAt: number | null; lastRunStatus: "success" | "error" | null; lastRunMessage: string; nextRunAt: number | null; runCount: number; createdBy: string; createdAt: number; updatedAt: number; } interface LogEntry { id: string; jobId: string; status: "success" | "error"; message: string; durationMs: number; timestamp: number; } const ACTION_TYPES = [ { value: "email", label: "Email" }, { value: "webhook", label: "Webhook" }, { value: "calendar-event", label: "Calendar Event" }, { value: "broadcast", label: "Broadcast" }, { value: "backlog-briefing", label: "Backlog Briefing" }, ]; const CRON_PRESETS = [ { label: "Every minute", value: "* * * * *" }, { label: "Every 5 minutes", value: "*/5 * * * *" }, { label: "Hourly", value: "0 * * * *" }, { label: "Daily at 9am", value: "0 9 * * *" }, { label: "Weekday mornings", value: "0 9 * * 1-5" }, { label: "Weekly (Monday 9am)", value: "0 9 * * 1" }, { label: "Monthly (1st at 9am)", value: "0 9 1 * *" }, { label: "Custom", value: "" }, ]; class FolkScheduleApp extends HTMLElement { private shadow: ShadowRoot; private space = ""; private jobs: JobData[] = []; private log: LogEntry[] = []; private view: "jobs" | "log" | "form" = "jobs"; private editingJob: JobData | null = null; private loading = false; private runningJobId: string | null = null; // Form state private formName = ""; private formDescription = ""; private formCron = "0 9 * * 1-5"; private formTimezone = "America/Vancouver"; private formActionType = "email"; private formEnabled = true; private formConfig: Record = {}; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.loadJobs(); } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/schedule/); return match ? match[0] : ""; } private async loadJobs() { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/jobs`); if (res.ok) { const data = await res.json(); this.jobs = data.results || []; } } catch { this.jobs = []; } this.loading = false; this.render(); } private async loadLog() { try { const base = this.getApiBase(); const res = await fetch(`${base}/api/log`); if (res.ok) { const data = await res.json(); this.log = data.results || []; } } catch { this.log = []; } this.render(); } private async toggleJob(id: string, enabled: boolean) { const base = this.getApiBase(); await fetch(`${base}/api/jobs/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), }); await this.loadJobs(); } private async deleteJob(id: string) { if (!confirm("Delete this scheduled job?")) return; const base = this.getApiBase(); await fetch(`${base}/api/jobs/${id}`, { method: "DELETE" }); await this.loadJobs(); } private async runJob(id: string) { this.runningJobId = id; this.render(); const base = this.getApiBase(); try { const res = await fetch(`${base}/api/jobs/${id}/run`, { method: "POST" }); const result = await res.json(); alert(result.success ? `Success: ${result.message}` : `Error: ${result.message}`); } catch (e: any) { alert(`Run failed: ${e.message}`); } this.runningJobId = null; await this.loadJobs(); } private async submitForm() { const base = this.getApiBase(); const payload: Record = { name: this.formName, description: this.formDescription, cronExpression: this.formCron, timezone: this.formTimezone, actionType: this.formActionType, actionConfig: { ...this.formConfig }, enabled: this.formEnabled, }; const isEdit = !!this.editingJob; const url = isEdit ? `${base}/api/jobs/${this.editingJob!.id}` : `${base}/api/jobs`; const method = isEdit ? "PUT" : "POST"; const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Request failed" })); alert(err.error || "Failed to save job"); return; } this.view = "jobs"; this.editingJob = null; await this.loadJobs(); } private openCreateForm() { this.editingJob = null; this.formName = ""; this.formDescription = ""; this.formCron = "0 9 * * 1-5"; this.formTimezone = "America/Vancouver"; this.formActionType = "email"; this.formEnabled = true; this.formConfig = {}; this.view = "form"; this.render(); } private openEditForm(job: JobData) { this.editingJob = job; this.formName = job.name; this.formDescription = job.description; this.formCron = job.cronExpression; this.formTimezone = job.timezone; this.formActionType = job.actionType; this.formEnabled = job.enabled; this.formConfig = {}; if (job.actionConfig) { for (const [k, v] of Object.entries(job.actionConfig)) { this.formConfig[k] = String(v); } } this.view = "form"; this.render(); } private formatTime(ts: number | null): string { if (!ts) return "—"; const d = new Date(ts); const now = Date.now(); const diff = now - ts; if (diff < 60_000) return "just now"; if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; return d.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); } private formatFuture(ts: number | null): string { if (!ts) return "—"; const diff = ts - Date.now(); if (diff < 0) return "overdue"; if (diff < 60_000) return "< 1m"; if (diff < 3600_000) return `in ${Math.floor(diff / 60_000)}m`; if (diff < 86400_000) return `in ${Math.floor(diff / 3600_000)}h`; return `in ${Math.floor(diff / 86400_000)}d`; } private renderActionConfigFields(): string { switch (this.formActionType) { case "email": return ` `; case "webhook": return ` `; case "calendar-event": return ` `; case "broadcast": return ` `; case "backlog-briefing": return ` `; default: return `

No configuration needed for this action type.

`; } } private esc(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } private render() { const styles = ` `; if (this.loading) { this.shadow.innerHTML = `${styles}
Loading schedule...
`; return; } let content = ""; if (this.view === "jobs") { content = this.renderJobList(); } else if (this.view === "log") { content = this.renderLog(); } else if (this.view === "form") { content = this.renderForm(); } this.shadow.innerHTML = ` ${styles}

rSchedule

${this.view === "jobs" ? `` : ""}
${content} `; this.attachListeners(); } private renderJobList(): string { if (this.jobs.length === 0) { return `

No scheduled jobs yet.

`; } const rows = this.jobs.map((j) => ` ${this.esc(j.name)} ${j.description ? `
${this.esc(j.description)}` : ""} ${this.esc(j.cronHuman || j.cronExpression)} ${this.esc(j.timezone)} ${this.esc(j.actionType)} ${this.formatTime(j.lastRunAt)} ${this.formatFuture(j.nextRunAt)}
`).join(""); return `
${rows}
On Job Schedule Timezone Action Last Run Next Run Actions
`; } private renderLog(): string { if (this.log.length === 0) { return `

No execution log entries yet.

Jobs will log their results here after they run.

`; } const jobNames = new Map(this.jobs.map((j) => [j.id, j.name])); const entries = this.log.map((e) => `
${new Date(e.timestamp).toLocaleString()} ${this.esc(jobNames.get(e.jobId) || e.jobId)} ${this.esc(e.message)} ${e.durationMs}ms
`).join(""); return `
${entries}
`; } private renderForm(): string { const isEdit = !!this.editingJob; const presetOptions = CRON_PRESETS.map((p) => `` ).join(""); const actionOptions = ACTION_TYPES.map((a) => `` ).join(""); return `

${isEdit ? "Edit Job" : "Create New Job"}

Action Configuration

${this.renderActionConfigFields()}
`; } private attachListeners() { // Tab switching this.shadow.querySelectorAll("[data-view]").forEach((btn) => { btn.addEventListener("click", () => { this.view = btn.dataset.view as "jobs" | "log"; if (this.view === "log") this.loadLog(); else this.render(); }); }); // Create button this.shadow.querySelectorAll("[data-action='create']").forEach((btn) => { btn.addEventListener("click", () => this.openCreateForm()); }); // Toggle this.shadow.querySelectorAll("[data-toggle]").forEach((input) => { input.addEventListener("change", () => { this.toggleJob(input.dataset.toggle!, input.checked); }); }); // Run this.shadow.querySelectorAll("[data-run]").forEach((btn) => { btn.addEventListener("click", () => this.runJob(btn.dataset.run!)); }); // Edit this.shadow.querySelectorAll("[data-edit]").forEach((btn) => { btn.addEventListener("click", () => { const job = this.jobs.find((j) => j.id === btn.dataset.edit); if (job) this.openEditForm(job); }); }); // Delete this.shadow.querySelectorAll("[data-delete]").forEach((btn) => { btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!)); }); // Form: cancel this.shadow.querySelector("[data-action='cancel']")?.addEventListener("click", () => { this.view = "jobs"; this.render(); }); // Form: submit this.shadow.querySelector("[data-action='submit']")?.addEventListener("click", () => { this.collectFormData(); this.submitForm(); }); // Form: preset selector this.shadow.querySelector("#f-preset")?.addEventListener("change", (e) => { const val = (e.target as HTMLSelectElement).value; if (val) { this.formCron = val; const cronInput = this.shadow.querySelector("#f-cron"); if (cronInput) cronInput.value = val; } }); // Form: action type change -> re-render config fields this.shadow.querySelector("#f-action")?.addEventListener("change", (e) => { this.collectFormData(); this.formActionType = (e.target as HTMLSelectElement).value; this.formConfig = {}; // reset config for new action type const container = this.shadow.querySelector("#f-config-fields"); if (container) container.innerHTML = this.renderActionConfigFields(); this.attachConfigListeners(); }); this.attachConfigListeners(); } private attachConfigListeners() { this.shadow.querySelectorAll("[data-config]").forEach((el) => { el.addEventListener("input", () => { this.formConfig[el.dataset.config!] = el.value; }); el.addEventListener("change", () => { this.formConfig[el.dataset.config!] = el.value; }); }); } private collectFormData() { const getName = this.shadow.querySelector("#f-name"); const getDesc = this.shadow.querySelector("#f-desc"); const getCron = this.shadow.querySelector("#f-cron"); const getTz = this.shadow.querySelector("#f-tz"); const getAction = this.shadow.querySelector("#f-action"); const getEnabled = this.shadow.querySelector("#f-enabled"); if (getName) this.formName = getName.value; if (getDesc) this.formDescription = getDesc.value; if (getCron) this.formCron = getCron.value; if (getTz) this.formTimezone = getTz.value; if (getAction) this.formActionType = getAction.value; if (getEnabled) this.formEnabled = getEnabled.checked; // Collect config fields this.shadow.querySelectorAll("[data-config]").forEach((el) => { this.formConfig[el.dataset.config!] = el.value; }); } } customElements.define("folk-schedule-app", FolkScheduleApp);