rspace-online/modules/rschedule/components/folk-schedule-app.ts

603 lines
23 KiB
TypeScript

/**
* <folk-schedule-app> — 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<string, unknown>;
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<string, string> = {};
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(/^(\/[^/]+)?\/rschedule/);
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<string, unknown> = {
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 `
<label class="s-label">To <input type="email" class="s-input" data-config="to" value="${this.esc(this.formConfig.to || "")}" placeholder="user@example.com"></label>
<label class="s-label">Subject <input type="text" class="s-input" data-config="subject" value="${this.esc(this.formConfig.subject || "")}" placeholder="[rSchedule] {{jobName}}"></label>
<label class="s-label">Body (HTML) <textarea class="s-input s-textarea" data-config="bodyTemplate" placeholder="<p>Hello from {{jobName}}</p>">${this.esc(this.formConfig.bodyTemplate || "")}</textarea></label>
`;
case "webhook":
return `
<label class="s-label">URL <input type="url" class="s-input" data-config="url" value="${this.esc(this.formConfig.url || "")}" placeholder="https://example.com/webhook"></label>
<label class="s-label">Method
<select class="s-input" data-config="method">
<option value="POST" ${this.formConfig.method === "POST" || !this.formConfig.method ? "selected" : ""}>POST</option>
<option value="GET" ${this.formConfig.method === "GET" ? "selected" : ""}>GET</option>
<option value="PUT" ${this.formConfig.method === "PUT" ? "selected" : ""}>PUT</option>
</select>
</label>
<label class="s-label">Body Template <textarea class="s-input s-textarea" data-config="bodyTemplate" placeholder='{"job":"{{jobName}}","ts":"{{timestamp}}"}'>${this.esc(this.formConfig.bodyTemplate || "")}</textarea></label>
`;
case "calendar-event":
return `
<label class="s-label">Event Title <input type="text" class="s-input" data-config="title" value="${this.esc(this.formConfig.title || "")}" placeholder="Recurring meeting"></label>
<label class="s-label">Duration (minutes) <input type="number" class="s-input" data-config="duration" value="${this.esc(this.formConfig.duration || "60")}" min="5" max="1440"></label>
`;
case "broadcast":
return `
<label class="s-label">Channel <input type="text" class="s-input" data-config="channel" value="${this.esc(this.formConfig.channel || "")}" placeholder="default"></label>
<label class="s-label">Message <textarea class="s-input s-textarea" data-config="message" placeholder="Scheduled broadcast">${this.esc(this.formConfig.message || "")}</textarea></label>
`;
case "backlog-briefing":
return `
<label class="s-label">Mode
<select class="s-input" data-config="mode">
<option value="morning" ${this.formConfig.mode === "morning" || !this.formConfig.mode ? "selected" : ""}>Morning</option>
<option value="weekly" ${this.formConfig.mode === "weekly" ? "selected" : ""}>Weekly</option>
<option value="monthly" ${this.formConfig.mode === "monthly" ? "selected" : ""}>Monthly</option>
</select>
</label>
<label class="s-label">To <input type="email" class="s-input" data-config="to" value="${this.esc(this.formConfig.to || "")}" placeholder="user@example.com"></label>
`;
default:
return `<p style="color:#94a3b8">No configuration needed for this action type.</p>`;
}
}
private esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
private render() {
const styles = `
<style>
:host { display:block; font-family:system-ui,-apple-system,sans-serif; color:#e2e8f0; }
.s-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:12px; }
.s-title { font-size:1.5rem; font-weight:700; margin:0; }
.s-tabs { display:flex; gap:4px; background:rgba(30,41,59,0.6); border-radius:8px; padding:3px; }
.s-tab { padding:6px 16px; border-radius:6px; border:none; background:transparent; color:#94a3b8; cursor:pointer; font-size:13px; transition:all 0.15s; }
.s-tab:hover { color:#e2e8f0; }
.s-tab.active { background:rgba(245,158,11,0.15); color:#f59e0b; }
.s-btn { padding:8px 16px; border-radius:8px; border:none; cursor:pointer; font-size:13px; font-weight:600; transition:all 0.15s; }
.s-btn-primary { background:linear-gradient(135deg,#f59e0b,#f97316); color:#0f172a; }
.s-btn-primary:hover { opacity:0.9; }
.s-btn-secondary { background:rgba(51,65,85,0.6); color:#e2e8f0; }
.s-btn-secondary:hover { background:rgba(71,85,105,0.6); }
.s-btn-danger { background:rgba(239,68,68,0.15); color:#ef4444; }
.s-btn-danger:hover { background:rgba(239,68,68,0.25); }
.s-btn-sm { padding:4px 10px; font-size:12px; }
.s-btn:disabled { opacity:0.5; cursor:not-allowed; }
.s-table { width:100%; border-collapse:collapse; }
.s-table th { text-align:left; padding:10px 12px; font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:#64748b; border-bottom:2px solid #1e293b; }
.s-table td { padding:10px 12px; border-bottom:1px solid rgba(30,41,59,0.6); font-size:14px; vertical-align:middle; }
.s-table tr:hover td { background:rgba(30,41,59,0.3); }
.s-status { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
.s-status-success { background:#22c55e; }
.s-status-error { background:#ef4444; }
.s-status-null { background:#475569; }
.s-toggle { position:relative; width:36px; height:20px; cursor:pointer; }
.s-toggle input { opacity:0; width:0; height:0; position:absolute; }
.s-toggle .slider { position:absolute; inset:0; background:#334155; border-radius:20px; transition:0.2s; }
.s-toggle .slider::before { content:''; position:absolute; height:14px; width:14px; left:3px; bottom:3px; background:#94a3b8; border-radius:50%; transition:0.2s; }
.s-toggle input:checked + .slider { background:rgba(245,158,11,0.3); }
.s-toggle input:checked + .slider::before { transform:translateX(16px); background:#f59e0b; }
.s-card { background:rgba(15,23,42,0.6); border:1px solid rgba(30,41,59,0.8); border-radius:12px; padding:24px; margin-bottom:16px; }
.s-form-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.s-form-full { grid-column:1/-1; }
.s-label { display:flex; flex-direction:column; gap:6px; font-size:13px; color:#94a3b8; font-weight:500; }
.s-input { padding:8px 12px; background:rgba(30,41,59,0.8); border:1px solid #334155; border-radius:6px; color:#e2e8f0; font-size:14px; font-family:inherit; }
.s-input:focus { outline:none; border-color:#f59e0b; }
.s-textarea { min-height:80px; resize:vertical; }
.s-actions { display:flex; gap:6px; flex-wrap:wrap; }
.s-empty { text-align:center; padding:48px 20px; color:#64748b; }
.s-loading { text-align:center; padding:48px; color:#94a3b8; }
.s-log-item { display:flex; gap:12px; padding:10px 0; border-bottom:1px solid rgba(30,41,59,0.6); font-size:13px; align-items:flex-start; }
.s-log-time { color:#64748b; white-space:nowrap; min-width:120px; }
.s-log-msg { color:#cbd5e1; flex:1; word-break:break-word; }
.s-log-dur { color:#64748b; white-space:nowrap; }
@media (max-width:768px) {
.s-form-grid { grid-template-columns:1fr; }
.s-table { font-size:12px; }
.s-table th, .s-table td { padding:8px 6px; }
}
</style>
`;
if (this.loading) {
this.shadow.innerHTML = `${styles}<div class="s-loading">Loading schedule...</div>`;
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}
<div class="s-header">
<h1 class="s-title">rSchedule</h1>
<div class="s-tabs">
<button class="s-tab ${this.view === "jobs" ? "active" : ""}" data-view="jobs">Jobs</button>
<button class="s-tab ${this.view === "log" ? "active" : ""}" data-view="log">Execution Log</button>
</div>
${this.view === "jobs" ? `<button class="s-btn s-btn-primary" data-action="create">+ New Job</button>` : ""}
</div>
${content}
`;
this.attachListeners();
}
private renderJobList(): string {
if (this.jobs.length === 0) {
return `<div class="s-empty"><p>No scheduled jobs yet.</p><p style="margin-top:8px"><button class="s-btn s-btn-primary" data-action="create">Create your first job</button></p></div>`;
}
const rows = this.jobs.map((j) => `
<tr>
<td>
<label class="s-toggle">
<input type="checkbox" ${j.enabled ? "checked" : ""} data-toggle="${j.id}">
<span class="slider"></span>
</label>
</td>
<td>
<strong style="color:#e2e8f0">${this.esc(j.name)}</strong>
${j.description ? `<br><span style="color:#64748b;font-size:12px">${this.esc(j.description)}</span>` : ""}
</td>
<td style="font-family:monospace;font-size:12px;color:#94a3b8" title="${this.esc(j.cronExpression)}">${this.esc(j.cronHuman || j.cronExpression)}</td>
<td style="color:#94a3b8;font-size:13px">${this.esc(j.timezone)}</td>
<td><span style="background:rgba(245,158,11,0.1);color:#f59e0b;padding:2px 8px;border-radius:4px;font-size:12px">${this.esc(j.actionType)}</span></td>
<td>
<span class="s-status s-status-${j.lastRunStatus || "null"}"></span>
<span style="color:#94a3b8;font-size:13px">${this.formatTime(j.lastRunAt)}</span>
</td>
<td style="color:#94a3b8;font-size:13px">${this.formatFuture(j.nextRunAt)}</td>
<td>
<div class="s-actions">
<button class="s-btn s-btn-secondary s-btn-sm" data-run="${j.id}" ${this.runningJobId === j.id ? "disabled" : ""}>${this.runningJobId === j.id ? "Running..." : "Run Now"}</button>
<button class="s-btn s-btn-secondary s-btn-sm" data-edit="${j.id}">Edit</button>
<button class="s-btn s-btn-danger s-btn-sm" data-delete="${j.id}">Delete</button>
</div>
</td>
</tr>
`).join("");
return `
<div style="overflow-x:auto">
<table class="s-table">
<thead>
<tr>
<th style="width:50px">On</th>
<th>Job</th>
<th>Schedule</th>
<th>Timezone</th>
<th>Action</th>
<th>Last Run</th>
<th>Next Run</th>
<th style="width:200px">Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
private renderLog(): string {
if (this.log.length === 0) {
return `<div class="s-empty"><p>No execution log entries yet.</p><p style="color:#64748b;margin-top:8px">Jobs will log their results here after they run.</p></div>`;
}
const jobNames = new Map(this.jobs.map((j) => [j.id, j.name]));
const entries = this.log.map((e) => `
<div class="s-log-item">
<span class="s-status s-status-${e.status}" style="margin-top:4px"></span>
<span class="s-log-time">${new Date(e.timestamp).toLocaleString()}</span>
<span style="color:#f59e0b;font-weight:500;min-width:140px">${this.esc(jobNames.get(e.jobId) || e.jobId)}</span>
<span class="s-log-msg">${this.esc(e.message)}</span>
<span class="s-log-dur">${e.durationMs}ms</span>
</div>
`).join("");
return `<div class="s-card">${entries}</div>`;
}
private renderForm(): string {
const isEdit = !!this.editingJob;
const presetOptions = CRON_PRESETS.map((p) =>
`<option value="${p.value}" ${this.formCron === p.value ? "selected" : ""}>${p.label}</option>`
).join("");
const actionOptions = ACTION_TYPES.map((a) =>
`<option value="${a.value}" ${this.formActionType === a.value ? "selected" : ""}>${a.label}</option>`
).join("");
return `
<div class="s-card">
<h2 style="margin:0 0 20px;font-size:1.1rem">${isEdit ? "Edit Job" : "Create New Job"}</h2>
<div class="s-form-grid">
<label class="s-label">Name <input type="text" class="s-input" id="f-name" value="${this.esc(this.formName)}" placeholder="My scheduled job"></label>
<label class="s-label">
Enabled
<label class="s-toggle" style="margin-top:4px">
<input type="checkbox" id="f-enabled" ${this.formEnabled ? "checked" : ""}>
<span class="slider"></span>
</label>
</label>
<label class="s-label s-form-full">Description <input type="text" class="s-input" id="f-desc" value="${this.esc(this.formDescription)}" placeholder="What this job does..."></label>
<label class="s-label">Cron Preset
<select class="s-input" id="f-preset">${presetOptions}</select>
</label>
<label class="s-label">Cron Expression <input type="text" class="s-input" id="f-cron" value="${this.esc(this.formCron)}" placeholder="* * * * *" style="font-family:monospace"></label>
<label class="s-label">Timezone <input type="text" class="s-input" id="f-tz" value="${this.esc(this.formTimezone)}" placeholder="America/Vancouver"></label>
<label class="s-label">Action Type
<select class="s-input" id="f-action">${actionOptions}</select>
</label>
<div class="s-form-full" style="border-top:1px solid #1e293b;padding-top:16px;margin-top:8px">
<h3 style="font-size:14px;color:#94a3b8;margin:0 0 12px">Action Configuration</h3>
<div class="s-form-grid" id="f-config-fields">
${this.renderActionConfigFields()}
</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:24px">
<button class="s-btn s-btn-primary" data-action="submit">${isEdit ? "Update Job" : "Create Job"}</button>
<button class="s-btn s-btn-secondary" data-action="cancel">Cancel</button>
</div>
</div>
`;
}
private attachListeners() {
// Tab switching
this.shadow.querySelectorAll<HTMLButtonElement>("[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<HTMLButtonElement>("[data-action='create']").forEach((btn) => {
btn.addEventListener("click", () => this.openCreateForm());
});
// Toggle
this.shadow.querySelectorAll<HTMLInputElement>("[data-toggle]").forEach((input) => {
input.addEventListener("change", () => {
this.toggleJob(input.dataset.toggle!, input.checked);
});
});
// Run
this.shadow.querySelectorAll<HTMLButtonElement>("[data-run]").forEach((btn) => {
btn.addEventListener("click", () => this.runJob(btn.dataset.run!));
});
// Edit
this.shadow.querySelectorAll<HTMLButtonElement>("[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<HTMLButtonElement>("[data-delete]").forEach((btn) => {
btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!));
});
// Form: cancel
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel']")?.addEventListener("click", () => {
this.view = "jobs";
this.render();
});
// Form: submit
this.shadow.querySelector<HTMLButtonElement>("[data-action='submit']")?.addEventListener("click", () => {
this.collectFormData();
this.submitForm();
});
// Form: preset selector
this.shadow.querySelector<HTMLSelectElement>("#f-preset")?.addEventListener("change", (e) => {
const val = (e.target as HTMLSelectElement).value;
if (val) {
this.formCron = val;
const cronInput = this.shadow.querySelector<HTMLInputElement>("#f-cron");
if (cronInput) cronInput.value = val;
}
});
// Form: action type change -> re-render config fields
this.shadow.querySelector<HTMLSelectElement>("#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<HTMLDivElement>("#f-config-fields");
if (container) container.innerHTML = this.renderActionConfigFields();
this.attachConfigListeners();
});
this.attachConfigListeners();
}
private attachConfigListeners() {
this.shadow.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>("[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<HTMLInputElement>("#f-name");
const getDesc = this.shadow.querySelector<HTMLInputElement>("#f-desc");
const getCron = this.shadow.querySelector<HTMLInputElement>("#f-cron");
const getTz = this.shadow.querySelector<HTMLInputElement>("#f-tz");
const getAction = this.shadow.querySelector<HTMLSelectElement>("#f-action");
const getEnabled = this.shadow.querySelector<HTMLInputElement>("#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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>("[data-config]").forEach((el) => {
this.formConfig[el.dataset.config!] = el.value;
});
}
}
customElements.define("folk-schedule-app", FolkScheduleApp);