diff --git a/modules/rminders/components/automation-canvas.css b/modules/rminders/components/automation-canvas.css new file mode 100644 index 00000000..c59f468a --- /dev/null +++ b/modules/rminders/components/automation-canvas.css @@ -0,0 +1,551 @@ +/* rMinders Automation Canvas — n8n-style workflow builder */ +folk-automation-canvas { + display: block; + height: 100%; +} + +.ac-root { + display: flex; + flex-direction: column; + height: 100%; + font-family: system-ui, -apple-system, sans-serif; + color: var(--rs-text-primary, #e2e8f0); +} + +/* ── Toolbar ── */ +.ac-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; + min-height: 46px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + z-index: 10; +} + +.ac-toolbar__title { + font-size: 15px; + font-weight: 600; + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.ac-toolbar__title input { + background: transparent; + border: 1px solid transparent; + color: var(--rs-text-primary, #e2e8f0); + font-size: 15px; + font-weight: 600; + padding: 2px 6px; + border-radius: 4px; + width: 200px; +} + +.ac-toolbar__title input:hover, +.ac-toolbar__title input:focus { + border-color: var(--rs-border-strong, #3d3d5c); + outline: none; +} + +.ac-toolbar__actions { + display: flex; + gap: 6px; + align-items: center; +} + +.ac-btn { + padding: 6px 12px; + border-radius: 8px; + border: 1px solid var(--rs-input-border, #3d3d5c); + background: var(--rs-input-bg, #16162a); + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + white-space: nowrap; +} + +.ac-btn:hover { + border-color: var(--rs-border-strong, #4d4d6c); +} + +.ac-btn--run { + background: #3b82f622; + border-color: #3b82f655; + color: #60a5fa; +} + +.ac-btn--run:hover { + background: #3b82f633; + border-color: #3b82f6; +} + +.ac-btn--save { + background: #10b98122; + border-color: #10b98155; + color: #34d399; +} + +.ac-toggle { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--rs-text-muted, #94a3b8); +} + +.ac-toggle input[type="checkbox"] { + accent-color: #10b981; +} + +.ac-save-indicator { + font-size: 11px; + color: var(--rs-text-muted, #64748b); +} + +/* ── Canvas area ── */ +.ac-canvas-area { + flex: 1; + display: flex; + overflow: hidden; + position: relative; +} + +/* ── Left sidebar — node palette ── */ +.ac-palette { + width: 200px; + min-width: 200px; + border-right: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.ac-palette__group-title { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--rs-text-muted, #94a3b8); + margin-bottom: 4px; +} + +.ac-palette__card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-input-bg, #16162a); + cursor: grab; + font-size: 12px; + transition: border-color 0.15s, background 0.15s; + margin-bottom: 4px; +} + +.ac-palette__card:hover { + border-color: #6366f1; + background: #6366f111; +} + +.ac-palette__card:active { + cursor: grabbing; +} + +.ac-palette__card-icon { + font-size: 16px; + width: 24px; + text-align: center; + flex-shrink: 0; +} + +.ac-palette__card-label { + font-weight: 500; + color: var(--rs-text-primary, #e2e8f0); +} + +/* ── SVG canvas ── */ +.ac-canvas { + flex: 1; + position: relative; + overflow: hidden; + cursor: grab; + background: var(--rs-canvas-bg, #0f0f23); +} + +.ac-canvas.grabbing { + cursor: grabbing; +} + +.ac-canvas.wiring { + cursor: crosshair; +} + +.ac-canvas svg { + display: block; + width: 100%; + height: 100%; +} + +/* ── Right sidebar — config ── */ +.ac-config { + width: 0; + overflow: hidden; + border-left: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + display: flex; + flex-direction: column; + transition: width 0.2s ease; +} + +.ac-config.open { + width: 280px; + min-width: 280px; +} + +.ac-config__header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + font-weight: 600; + font-size: 13px; +} + +.ac-config__header-close { + background: none; + border: none; + color: var(--rs-text-muted, #94a3b8); + font-size: 16px; + cursor: pointer; + margin-left: auto; + padding: 2px; +} + +.ac-config__body { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + flex: 1; +} + +.ac-config__field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ac-config__field label { + font-size: 11px; + font-weight: 500; + color: var(--rs-text-muted, #94a3b8); +} + +.ac-config__field input, +.ac-config__field select, +.ac-config__field textarea { + width: 100%; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--rs-input-border, #3d3d5c); + background: var(--rs-input-bg, #16162a); + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; + font-family: inherit; + box-sizing: border-box; +} + +.ac-config__field textarea { + resize: vertical; + min-height: 60px; +} + +.ac-config__field input:focus, +.ac-config__field select:focus, +.ac-config__field textarea:focus { + border-color: #3b82f6; + outline: none; +} + +.ac-config__delete { + margin-top: 12px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid #ef444455; + background: #ef444422; + color: #f87171; + font-size: 12px; + cursor: pointer; +} + +.ac-config__delete:hover { + background: #ef444433; +} + +/* ── Execution log in config panel ── */ +.ac-exec-log { + margin-top: 12px; + border-top: 1px solid var(--rs-border, #2d2d44); + padding-top: 12px; +} + +.ac-exec-log__title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--rs-text-muted, #94a3b8); + margin-bottom: 8px; +} + +.ac-exec-log__entry { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); +} + +.ac-exec-log__dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.ac-exec-log__dot.success { background: #22c55e; } +.ac-exec-log__dot.error { background: #ef4444; } +.ac-exec-log__dot.running { background: #3b82f6; animation: ac-pulse 1s infinite; } + +@keyframes ac-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ── Zoom controls ── */ +.ac-zoom-controls { + position: absolute; + bottom: 12px; + right: 12px; + display: flex; + align-items: center; + gap: 4px; + background: var(--rs-bg-surface, #1a1a2e); + border: 1px solid var(--rs-border-strong, #3d3d5c); + border-radius: 8px; + padding: 4px 6px; + z-index: 5; +} + +.ac-zoom-btn { + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--rs-text-primary, #e2e8f0); + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.ac-zoom-btn:hover { + background: var(--rs-bg-surface-raised, #252545); +} + +.ac-zoom-level { + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + min-width: 36px; + text-align: center; +} + +/* ── Node styles in SVG ── */ +.ac-node { cursor: pointer; } +.ac-node.selected > foreignObject > div { + outline: 2px solid #6366f1; + outline-offset: 2px; +} + +.ac-node foreignObject > div { + border-radius: 10px; + border: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + overflow: hidden; + font-size: 12px; + transition: border-color 0.15s; +} + +.ac-node:hover foreignObject > div { + border-color: #4f46e5 !important; +} + +.ac-node-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + cursor: move; +} + +.ac-node-icon { + font-size: 14px; +} + +.ac-node-label { + font-weight: 600; + font-size: 12px; + color: var(--rs-text-primary, #e2e8f0); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ac-node-status { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.ac-node-status.idle { background: #4b5563; } +.ac-node-status.running { background: #3b82f6; animation: ac-pulse 1s infinite; } +.ac-node-status.success { background: #22c55e; } +.ac-node-status.error { background: #ef4444; } + +.ac-node-ports { + padding: 6px 10px; + display: flex; + justify-content: space-between; + min-height: 28px; +} + +.ac-node-inputs, +.ac-node-outputs { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ac-node-outputs { + align-items: flex-end; +} + +.ac-port-label { + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); +} + +/* ── Port handles in SVG ── */ +.ac-port-group { cursor: crosshair; } +.ac-port-dot { + transition: r 0.15s, filter 0.15s; +} + +.ac-port-group:hover .ac-port-dot { + r: 7; + filter: drop-shadow(0 0 4px currentColor); +} + +.ac-port-group--wiring-source .ac-port-dot { + r: 7; + filter: drop-shadow(0 0 6px currentColor); +} + +.ac-port-group--wiring-target .ac-port-dot { + r: 7; + animation: port-pulse 0.8s ease-in-out infinite; +} + +.ac-port-group--wiring-dimmed .ac-port-dot { + opacity: 0.2; +} + +@keyframes port-pulse { + 0%, 100% { filter: drop-shadow(0 0 3px currentColor); } + 50% { filter: drop-shadow(0 0 8px currentColor); } +} + +/* ── Edge styles ── */ +.ac-edge-group { pointer-events: stroke; } + +.ac-edge-path { + fill: none; + stroke-width: 2; +} + +.ac-edge-hit { + fill: none; + stroke: transparent; + stroke-width: 16; + cursor: pointer; +} + +.ac-edge-path.running { + animation: ac-edge-flow 1s linear infinite; + stroke-dasharray: 8 4; +} + +@keyframes ac-edge-flow { + to { stroke-dashoffset: -24; } +} + +/* ── Wiring temp line ── */ +.ac-wiring-temp { + fill: none; + stroke: #6366f1; + stroke-width: 2; + stroke-dasharray: 6 4; + opacity: 0.7; + pointer-events: none; +} + +/* ── Workflow selector ── */ +.ac-workflow-select { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--rs-input-border, #3d3d5c); + background: var(--rs-input-bg, #16162a); + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; +} + +/* ── Mobile ── */ +@media (max-width: 768px) { + .ac-palette { + width: 160px; + min-width: 160px; + padding: 8px; + } + + .ac-config.open { + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + z-index: 20; + min-width: unset; + } + + .ac-toolbar { + flex-wrap: wrap; + padding: 8px 12px; + } +} diff --git a/modules/rminders/components/folk-automation-canvas.ts b/modules/rminders/components/folk-automation-canvas.ts new file mode 100644 index 00000000..75ec9128 --- /dev/null +++ b/modules/rminders/components/folk-automation-canvas.ts @@ -0,0 +1,1007 @@ +/** + * — n8n-style automation workflow builder for rMinders. + * + * Renders workflow nodes (triggers, conditions, actions) on an SVG canvas + * with ports, Bezier wiring, node palette, config panel, and REST persistence. + * + * Attributes: + * space — space slug (default "demo") + */ + +import { NODE_CATALOG } from '../schemas'; +import type { AutomationNodeDef, AutomationNodeCategory, WorkflowNode, WorkflowEdge, Workflow } from '../schemas'; + +// ── Constants ── + +const NODE_WIDTH = 220; +const NODE_HEIGHT = 80; +const PORT_RADIUS = 5; + +const CATEGORY_COLORS: Record = { + trigger: '#3b82f6', + condition: '#f59e0b', + action: '#10b981', +}; + +const PORT_COLORS: Record = { + trigger: '#ef4444', + data: '#3b82f6', + boolean: '#f59e0b', +}; + +// ── Helpers ── + +function esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s || ''; + return d.innerHTML; +} + +function getNodeDef(type: string): AutomationNodeDef | undefined { + return NODE_CATALOG.find(n => n.type === type); +} + +function getPortX(node: WorkflowNode, portName: string, direction: 'input' | 'output'): number { + return direction === 'input' ? node.position.x : node.position.x + NODE_WIDTH; +} + +function getPortY(node: WorkflowNode, portName: string, direction: 'input' | 'output'): number { + const def = getNodeDef(node.type); + if (!def) return node.position.y + NODE_HEIGHT / 2; + const ports = direction === 'input' ? def.inputs : def.outputs; + const idx = ports.findIndex(p => p.name === portName); + if (idx === -1) return node.position.y + NODE_HEIGHT / 2; + const spacing = NODE_HEIGHT / (ports.length + 1); + return node.position.y + spacing * (idx + 1); +} + +function bezierPath(x1: number, y1: number, x2: number, y2: number): string { + const dx = Math.abs(x2 - x1) * 0.5; + return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; +} + +// ── Component ── + +class FolkAutomationCanvas extends HTMLElement { + private shadow: ShadowRoot; + private space = ''; + + private get basePath() { + const host = window.location.hostname; + if (host.endsWith('.rspace.online')) return '/rminders/'; + return `/${this.space}/rminders/`; + } + + // Data + private workflows: Workflow[] = []; + private currentWorkflowId = ''; + private nodes: WorkflowNode[] = []; + private edges: WorkflowEdge[] = []; + private workflowName = 'New Workflow'; + private workflowEnabled = true; + + // Canvas state + private canvasZoom = 1; + private canvasPanX = 0; + private canvasPanY = 0; + + // Interaction + private isPanning = false; + private panStartX = 0; + private panStartY = 0; + private panStartPanX = 0; + private panStartPanY = 0; + private draggingNodeId: string | null = null; + private dragStartX = 0; + private dragStartY = 0; + private dragNodeStartX = 0; + private dragNodeStartY = 0; + + // Selection & config + private selectedNodeId: string | null = null; + private configOpen = false; + + // Wiring + private wiringActive = false; + private wiringSourceNodeId: string | null = null; + private wiringSourcePortName: string | null = null; + private wiringSourceDir: 'input' | 'output' | null = null; + private wiringPointerX = 0; + private wiringPointerY = 0; + + // Persistence + private saveTimer: ReturnType | null = null; + private saveIndicator = ''; + + // Execution log + private execLog: { nodeId: string; status: string; message: string; durationMs: number }[] = []; + + // Bound listeners + private _boundPointerMove: ((e: PointerEvent) => void) | null = null; + private _boundPointerUp: ((e: PointerEvent) => void) | null = null; + private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.space = this.getAttribute('space') || 'demo'; + this.initData(); + } + + disconnectedCallback() { + if (this.saveTimer) clearTimeout(this.saveTimer); + if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove); + if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp); + if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown); + } + + // ── Data init ── + + private async initData() { + try { + const res = await fetch(`${this.basePath}api/workflows`); + if (res.ok) { + const data = await res.json(); + this.workflows = data.results || []; + if (this.workflows.length > 0) { + this.loadWorkflow(this.workflows[0]); + } + } + } catch { + console.warn('[AutomationCanvas] Failed to load workflows'); + } + this.render(); + requestAnimationFrame(() => this.fitView()); + } + + private loadWorkflow(wf: Workflow) { + this.currentWorkflowId = wf.id; + this.workflowName = wf.name; + this.workflowEnabled = wf.enabled; + this.nodes = wf.nodes.map(n => ({ ...n, position: { ...n.position } })); + this.edges = wf.edges.map(e => ({ ...e })); + this.selectedNodeId = null; + this.configOpen = false; + this.execLog = []; + } + + // ── Persistence ── + + private scheduleSave() { + this.saveIndicator = 'Saving...'; + this.updateSaveIndicator(); + if (this.saveTimer) clearTimeout(this.saveTimer); + this.saveTimer = setTimeout(() => { this.executeSave(); this.saveTimer = null; }, 1500); + } + + private async executeSave() { + if (!this.currentWorkflowId) return; + try { + await fetch(`${this.basePath}api/workflows/${this.currentWorkflowId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: this.workflowName, + enabled: this.workflowEnabled, + nodes: this.nodes, + edges: this.edges, + }), + }); + this.saveIndicator = 'Saved'; + } catch { + this.saveIndicator = 'Save failed'; + } + this.updateSaveIndicator(); + setTimeout(() => { this.saveIndicator = ''; this.updateSaveIndicator(); }, 2000); + } + + private async createWorkflow() { + try { + const res = await fetch(`${this.basePath}api/workflows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'New Workflow' }), + }); + if (res.ok) { + const wf = await res.json(); + this.workflows.push(wf); + this.loadWorkflow(wf); + this.render(); + requestAnimationFrame(() => this.fitView()); + } + } catch { + console.error('[AutomationCanvas] Failed to create workflow'); + } + } + + private async deleteWorkflow() { + if (!this.currentWorkflowId) return; + try { + await fetch(`${this.basePath}api/workflows/${this.currentWorkflowId}`, { method: 'DELETE' }); + this.workflows = this.workflows.filter(w => w.id !== this.currentWorkflowId); + if (this.workflows.length > 0) { + this.loadWorkflow(this.workflows[0]); + } else { + this.currentWorkflowId = ''; + this.nodes = []; + this.edges = []; + this.workflowName = ''; + } + this.render(); + } catch { + console.error('[AutomationCanvas] Failed to delete workflow'); + } + } + + // ── Canvas transform ── + + private updateCanvasTransform() { + const g = this.shadow.getElementById('canvas-transform'); + if (g) g.setAttribute('transform', `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`); + this.updateZoomDisplay(); + } + + private updateZoomDisplay() { + const el = this.shadow.getElementById('zoom-level'); + if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`; + } + + private updateSaveIndicator() { + const el = this.shadow.getElementById('save-indicator'); + if (el) el.textContent = this.saveIndicator; + } + + private fitView() { + const svg = this.shadow.getElementById('ac-svg') as SVGSVGElement | null; + if (!svg || this.nodes.length === 0) return; + const rect = svg.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of this.nodes) { + minX = Math.min(minX, n.position.x); + minY = Math.min(minY, n.position.y); + maxX = Math.max(maxX, n.position.x + NODE_WIDTH); + maxY = Math.max(maxY, n.position.y + NODE_HEIGHT); + } + + const pad = 60; + const contentW = maxX - minX + pad * 2; + const contentH = maxY - minY + pad * 2; + const scaleX = rect.width / contentW; + const scaleY = rect.height / contentH; + this.canvasZoom = Math.min(scaleX, scaleY, 1.5); + this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom; + this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom; + this.updateCanvasTransform(); + } + + private zoomAt(screenX: number, screenY: number, factor: number) { + const oldZoom = this.canvasZoom; + const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor)); + this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom); + this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom); + this.canvasZoom = newZoom; + this.updateCanvasTransform(); + } + + // ── Rendering ── + + private render() { + const paletteGroups = ['trigger', 'condition', 'action'] as AutomationNodeCategory[]; + + this.shadow.innerHTML = ` + +
+
+
+ Automations + + +
+
+
+ + +
+ ${this.saveIndicator} + + + +
+
+ +
+
+ ${paletteGroups.map(cat => ` +
+
${cat}s
+ ${NODE_CATALOG.filter(n => n.category === cat).map(n => ` +
+ ${n.icon} + ${esc(n.label)} +
+ `).join('')} +
+ `).join('')} +
+ +
+ + + ${this.renderAllEdges()} + + ${this.renderAllNodes()} + + +
+ + ${Math.round(this.canvasZoom * 100)}% + + +
+
+ +
+ ${this.renderConfigPanel()} +
+
+
+ `; + + this.attachEventListeners(); + } + + private renderAllNodes(): string { + return this.nodes.map(node => this.renderNode(node)).join(''); + } + + private renderNode(node: WorkflowNode): string { + const def = getNodeDef(node.type); + if (!def) return ''; + const catColor = CATEGORY_COLORS[def.category]; + const status = node.runtimeStatus || 'idle'; + const isSelected = node.id === this.selectedNodeId; + + // Ports + let portsHtml = ''; + for (const inp of def.inputs) { + const y = getPortY(node, inp.name, 'input'); + const x = node.position.x; + const color = PORT_COLORS[inp.type] || '#6b7280'; + portsHtml += ` + + + + `; + } + for (const out of def.outputs) { + const y = getPortY(node, out.name, 'output'); + const x = node.position.x + NODE_WIDTH; + const color = PORT_COLORS[out.type] || '#6b7280'; + portsHtml += ` + + + + `; + } + + // Input port labels + let portLabelHtml = ''; + for (const inp of def.inputs) { + const y = getPortY(node, inp.name, 'input'); + portLabelHtml += `${inp.name}`; + } + for (const out of def.outputs) { + const y = getPortY(node, out.name, 'output'); + portLabelHtml += `${out.name}`; + } + + return ` + + +
+
+ ${def.icon} + ${esc(node.label)} + +
+
+
+ ${def.inputs.map(p => `${p.name}`).join('')} +
+
+ ${def.outputs.map(p => `${p.name}`).join('')} +
+
+
+
+ ${portsHtml} + ${portLabelHtml} +
`; + } + + private renderAllEdges(): string { + return this.edges.map(edge => { + const fromNode = this.nodes.find(n => n.id === edge.fromNode); + const toNode = this.nodes.find(n => n.id === edge.toNode); + if (!fromNode || !toNode) return ''; + + const x1 = getPortX(fromNode, edge.fromPort, 'output'); + const y1 = getPortY(fromNode, edge.fromPort, 'output'); + const x2 = getPortX(toNode, edge.toPort, 'input'); + const y2 = getPortY(toNode, edge.toPort, 'input'); + + const fromDef = getNodeDef(fromNode.type); + const outPort = fromDef?.outputs.find(p => p.name === edge.fromPort); + const color = outPort ? (PORT_COLORS[outPort.type] || '#6b7280') : '#6b7280'; + const d = bezierPath(x1, y1, x2, y2); + + return ` + + + + `; + }).join(''); + } + + private renderConfigPanel(): string { + if (!this.selectedNodeId) { + return ` +
+ No node selected + +
+
+

Click a node to configure it.

+
`; + } + + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (!node) return ''; + const def = getNodeDef(node.type); + if (!def) return ''; + + const fieldsHtml = def.configSchema.map(field => { + const val = node.config[field.key] ?? ''; + if (field.type === 'select') { + const options = (field.options || []).map(o => + `` + ).join(''); + return ` +
+ + +
`; + } + if (field.type === 'textarea') { + return ` +
+ + +
`; + } + return ` +
+ + +
`; + }).join(''); + + const logHtml = this.execLog.filter(e => e.nodeId === this.selectedNodeId).map(e => ` +
+ + ${esc(e.message)} (${e.durationMs}ms) +
+ `).join(''); + + return ` +
+ ${def.icon} ${esc(node.label)} + +
+
+
+ + +
+ ${fieldsHtml} + ${logHtml ? `
Execution Log
${logHtml}
` : ''} + +
`; + } + + // ── Redraw helpers ── + + private drawCanvasContent() { + const edgeLayer = this.shadow.getElementById('edge-layer'); + const nodeLayer = this.shadow.getElementById('node-layer'); + const wireLayer = this.shadow.getElementById('wire-layer'); + if (!edgeLayer || !nodeLayer) return; + edgeLayer.innerHTML = this.renderAllEdges(); + nodeLayer.innerHTML = this.renderAllNodes(); + if (wireLayer) wireLayer.innerHTML = ''; + } + + private redrawEdges() { + const edgeLayer = this.shadow.getElementById('edge-layer'); + if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges(); + } + + private updateNodePosition(node: WorkflowNode) { + const nodeLayer = this.shadow.getElementById('node-layer'); + if (!nodeLayer) return; + const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; + if (!g) return; + const fo = g.querySelector('foreignObject'); + if (fo) { + fo.setAttribute('x', String(node.position.x)); + fo.setAttribute('y', String(node.position.y)); + } + // Update port circle positions + const def = getNodeDef(node.type); + if (!def) return; + const portGroups = g.querySelectorAll('.ac-port-group'); + portGroups.forEach(pg => { + const portName = (pg as HTMLElement).dataset.portName!; + const dir = (pg as HTMLElement).dataset.portDir as 'input' | 'output'; + const x = dir === 'input' ? node.position.x : node.position.x + NODE_WIDTH; + const ports = dir === 'input' ? def.inputs : def.outputs; + const idx = ports.findIndex(p => p.name === portName); + const spacing = NODE_HEIGHT / (ports.length + 1); + const y = node.position.y + spacing * (idx + 1); + pg.querySelectorAll('circle').forEach(c => { + c.setAttribute('cx', String(x)); + c.setAttribute('cy', String(y)); + }); + }); + // Update port labels + const labels = g.querySelectorAll('text'); + let labelIdx = 0; + for (const inp of def.inputs) { + if (labels[labelIdx]) { + const y = getPortY(node, inp.name, 'input'); + labels[labelIdx].setAttribute('x', String(node.position.x + 14)); + labels[labelIdx].setAttribute('y', String(y + 4)); + } + labelIdx++; + } + for (const out of def.outputs) { + if (labels[labelIdx]) { + const y = getPortY(node, out.name, 'output'); + labels[labelIdx].setAttribute('x', String(node.position.x + NODE_WIDTH - 14)); + labels[labelIdx].setAttribute('y', String(y + 4)); + } + labelIdx++; + } + } + + private refreshConfigPanel() { + const panel = this.shadow.getElementById('config-panel'); + if (!panel) return; + panel.className = `ac-config ${this.configOpen ? 'open' : ''}`; + panel.innerHTML = this.renderConfigPanel(); + this.attachConfigListeners(); + } + + // ── Node operations ── + + private addNode(type: string, x: number, y: number) { + const def = getNodeDef(type); + if (!def) return; + const id = `n-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node: WorkflowNode = { + id, + type: def.type, + label: def.label, + position: { x, y }, + config: {}, + }; + this.nodes.push(node); + this.drawCanvasContent(); + this.selectNode(id); + this.scheduleSave(); + } + + private deleteNode(nodeId: string) { + this.nodes = this.nodes.filter(n => n.id !== nodeId); + this.edges = this.edges.filter(e => e.fromNode !== nodeId && e.toNode !== nodeId); + if (this.selectedNodeId === nodeId) { + this.selectedNodeId = null; + this.configOpen = false; + } + this.drawCanvasContent(); + this.refreshConfigPanel(); + this.scheduleSave(); + } + + private selectNode(nodeId: string) { + this.selectedNodeId = nodeId; + this.configOpen = true; + // Update selection in SVG + const nodeLayer = this.shadow.getElementById('node-layer'); + if (nodeLayer) { + nodeLayer.querySelectorAll('.ac-node').forEach(g => { + g.classList.toggle('selected', g.getAttribute('data-node-id') === nodeId); + }); + } + this.refreshConfigPanel(); + } + + // ── Wiring ── + + private enterWiring(nodeId: string, portName: string, dir: 'input' | 'output') { + // Only start wiring from output ports + if (dir !== 'output') return; + this.wiringActive = true; + this.wiringSourceNodeId = nodeId; + this.wiringSourcePortName = portName; + this.wiringSourceDir = dir; + const canvas = this.shadow.getElementById('ac-canvas'); + if (canvas) canvas.classList.add('wiring'); + } + + private cancelWiring() { + this.wiringActive = false; + this.wiringSourceNodeId = null; + this.wiringSourcePortName = null; + this.wiringSourceDir = null; + const canvas = this.shadow.getElementById('ac-canvas'); + if (canvas) canvas.classList.remove('wiring'); + const wireLayer = this.shadow.getElementById('wire-layer'); + if (wireLayer) wireLayer.innerHTML = ''; + } + + private completeWiring(targetNodeId: string, targetPortName: string, targetDir: 'input' | 'output') { + if (!this.wiringSourceNodeId || !this.wiringSourcePortName) { this.cancelWiring(); return; } + if (targetDir !== 'input') { this.cancelWiring(); return; } + if (targetNodeId === this.wiringSourceNodeId) { this.cancelWiring(); return; } + + // Check for duplicate edges + const exists = this.edges.some(e => + e.fromNode === this.wiringSourceNodeId && e.fromPort === this.wiringSourcePortName && + e.toNode === targetNodeId && e.toPort === targetPortName + ); + if (exists) { this.cancelWiring(); return; } + + const edgeId = `e-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + this.edges.push({ + id: edgeId, + fromNode: this.wiringSourceNodeId, + fromPort: this.wiringSourcePortName, + toNode: targetNodeId, + toPort: targetPortName, + }); + + this.cancelWiring(); + this.drawCanvasContent(); + this.scheduleSave(); + } + + private updateWiringTempLine() { + const svg = this.shadow.getElementById('ac-svg') as SVGSVGElement | null; + const wireLayer = this.shadow.getElementById('wire-layer'); + if (!svg || !wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortName) return; + + const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId); + if (!sourceNode) return; + + const x1 = getPortX(sourceNode, this.wiringSourcePortName!, 'output'); + const y1 = getPortY(sourceNode, this.wiringSourcePortName!, 'output'); + + const rect = svg.getBoundingClientRect(); + const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom; + const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom; + + const d = bezierPath(x1, y1, x2, y2); + wireLayer.innerHTML = ``; + } + + // ── Execution ── + + private async runWorkflow() { + if (!this.currentWorkflowId) return; + // Reset runtime statuses + for (const n of this.nodes) { + n.runtimeStatus = 'running'; + } + this.drawCanvasContent(); + + try { + const res = await fetch(`${this.basePath}api/workflows/${this.currentWorkflowId}/run`, { method: 'POST' }); + const data = await res.json(); + this.execLog = data.results || []; + + // Update node statuses + for (const n of this.nodes) { + const logEntry = this.execLog.find(e => e.nodeId === n.id); + n.runtimeStatus = logEntry ? (logEntry.status as 'success' | 'error') : 'idle'; + n.runtimeMessage = logEntry?.message; + } + } catch { + for (const n of this.nodes) { + n.runtimeStatus = 'error'; + } + } + + this.drawCanvasContent(); + if (this.selectedNodeId) this.refreshConfigPanel(); + + // Reset after 5s + setTimeout(() => { + for (const n of this.nodes) { + n.runtimeStatus = 'idle'; + n.runtimeMessage = undefined; + } + this.drawCanvasContent(); + }, 5000); + } + + // ── Event listeners ── + + private attachEventListeners() { + const canvas = this.shadow.getElementById('ac-canvas')!; + const svg = this.shadow.getElementById('ac-svg')!; + const palette = this.shadow.getElementById('palette')!; + + // Toolbar + this.shadow.getElementById('wf-name')?.addEventListener('input', (e) => { + this.workflowName = (e.target as HTMLInputElement).value; + this.scheduleSave(); + }); + + this.shadow.getElementById('wf-enabled')?.addEventListener('change', (e) => { + this.workflowEnabled = (e.target as HTMLInputElement).checked; + this.scheduleSave(); + }); + + this.shadow.getElementById('btn-run')?.addEventListener('click', () => this.runWorkflow()); + this.shadow.getElementById('btn-new')?.addEventListener('click', () => this.createWorkflow()); + this.shadow.getElementById('btn-delete')?.addEventListener('click', () => this.deleteWorkflow()); + + this.shadow.getElementById('workflow-select')?.addEventListener('change', (e) => { + const id = (e.target as HTMLSelectElement).value; + const wf = this.workflows.find(w => w.id === id); + if (wf) { + this.loadWorkflow(wf); + this.drawCanvasContent(); + this.refreshConfigPanel(); + requestAnimationFrame(() => this.fitView()); + } + }); + + // Zoom controls + this.shadow.getElementById('zoom-in')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 1.2); + }); + this.shadow.getElementById('zoom-out')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 0.8); + }); + this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView()); + + // Canvas mouse wheel + canvas.addEventListener('wheel', (e: WheelEvent) => { + e.preventDefault(); + const rect = svg.getBoundingClientRect(); + const factor = e.deltaY < 0 ? 1.1 : 0.9; + this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor); + }, { passive: false }); + + // Palette drag + palette.querySelectorAll('.ac-palette__card').forEach(card => { + card.addEventListener('dragstart', (e: Event) => { + const de = e as DragEvent; + const type = (card as HTMLElement).dataset.nodeType!; + de.dataTransfer?.setData('text/plain', type); + }); + }); + + // Canvas drop + canvas.addEventListener('dragover', (e: DragEvent) => { e.preventDefault(); }); + canvas.addEventListener('drop', (e: DragEvent) => { + e.preventDefault(); + const type = e.dataTransfer?.getData('text/plain'); + if (!type) return; + const rect = svg.getBoundingClientRect(); + const x = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom; + const y = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom; + this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2); + }); + + // SVG pointer events + svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e)); + + // Global move/up + this._boundPointerMove = (e: PointerEvent) => this.handlePointerMove(e); + this._boundPointerUp = (e: PointerEvent) => this.handlePointerUp(e); + this._boundKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e); + document.addEventListener('pointermove', this._boundPointerMove); + document.addEventListener('pointerup', this._boundPointerUp); + document.addEventListener('keydown', this._boundKeyDown); + + // Config panel + this.attachConfigListeners(); + } + + private attachConfigListeners() { + this.shadow.getElementById('config-close')?.addEventListener('click', () => { + this.configOpen = false; + this.selectedNodeId = null; + const panel = this.shadow.getElementById('config-panel'); + if (panel) panel.className = 'ac-config'; + this.drawCanvasContent(); + }); + + this.shadow.getElementById('config-label')?.addEventListener('input', (e) => { + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (node) { + node.label = (e.target as HTMLInputElement).value; + this.drawCanvasContent(); + this.scheduleSave(); + } + }); + + this.shadow.getElementById('config-delete-node')?.addEventListener('click', () => { + if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); + }); + + // Config field inputs + const configPanel = this.shadow.getElementById('config-panel'); + if (configPanel) { + configPanel.querySelectorAll('[data-config-key]').forEach(el => { + el.addEventListener('input', (e) => { + const key = (el as HTMLElement).dataset.configKey!; + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (node) { + node.config[key] = (e.target as HTMLInputElement).value; + this.scheduleSave(); + } + }); + el.addEventListener('change', (e) => { + const key = (el as HTMLElement).dataset.configKey!; + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (node) { + node.config[key] = (e.target as HTMLSelectElement).value; + this.scheduleSave(); + } + }); + }); + } + } + + private handlePointerDown(e: PointerEvent) { + const svg = this.shadow.getElementById('ac-svg') as unknown as SVGSVGElement; + const target = e.target as Element; + + // Port click — start/complete wiring + const portGroup = target.closest('.ac-port-group') as SVGElement | null; + if (portGroup) { + e.stopPropagation(); + const nodeId = portGroup.dataset.nodeId!; + const portName = portGroup.dataset.portName!; + const dir = portGroup.dataset.portDir as 'input' | 'output'; + + if (this.wiringActive) { + this.completeWiring(nodeId, portName, dir); + } else { + this.enterWiring(nodeId, portName, dir); + } + return; + } + + // Edge click — delete + const edgeGroup = target.closest('.ac-edge-group') as SVGElement | null; + if (edgeGroup) { + e.stopPropagation(); + const edgeId = edgeGroup.dataset.edgeId!; + this.edges = this.edges.filter(ed => ed.id !== edgeId); + this.redrawEdges(); + this.scheduleSave(); + return; + } + + // Node click — select + start drag + const nodeGroup = target.closest('.ac-node') as SVGElement | null; + if (nodeGroup) { + e.stopPropagation(); + if (this.wiringActive) { + this.cancelWiring(); + return; + } + const nodeId = nodeGroup.dataset.nodeId!; + this.selectNode(nodeId); + + const node = this.nodes.find(n => n.id === nodeId); + if (node) { + this.draggingNodeId = nodeId; + this.dragStartX = e.clientX; + this.dragStartY = e.clientY; + this.dragNodeStartX = node.position.x; + this.dragNodeStartY = node.position.y; + } + return; + } + + // Canvas click — pan or deselect + if (this.wiringActive) { + this.cancelWiring(); + return; + } + + this.isPanning = true; + this.panStartX = e.clientX; + this.panStartY = e.clientY; + this.panStartPanX = this.canvasPanX; + this.panStartPanY = this.canvasPanY; + const canvas = this.shadow.getElementById('ac-canvas'); + if (canvas) canvas.classList.add('grabbing'); + + // Deselect + if (this.selectedNodeId) { + this.selectedNodeId = null; + this.configOpen = false; + this.drawCanvasContent(); + this.refreshConfigPanel(); + } + } + + private handlePointerMove(e: PointerEvent) { + if (this.wiringActive) { + this.wiringPointerX = e.clientX; + this.wiringPointerY = e.clientY; + this.updateWiringTempLine(); + return; + } + + if (this.draggingNodeId) { + const node = this.nodes.find(n => n.id === this.draggingNodeId); + if (node) { + const dx = (e.clientX - this.dragStartX) / this.canvasZoom; + const dy = (e.clientY - this.dragStartY) / this.canvasZoom; + node.position.x = this.dragNodeStartX + dx; + node.position.y = this.dragNodeStartY + dy; + this.updateNodePosition(node); + this.redrawEdges(); + } + return; + } + + if (this.isPanning) { + this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX); + this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY); + this.updateCanvasTransform(); + } + } + + private handlePointerUp(_e: PointerEvent) { + if (this.draggingNodeId) { + this.draggingNodeId = null; + this.scheduleSave(); + } + if (this.isPanning) { + this.isPanning = false; + const canvas = this.shadow.getElementById('ac-canvas'); + if (canvas) canvas.classList.remove('grabbing'); + } + } + + private handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + if (this.wiringActive) this.cancelWiring(); + } + if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedNodeId) { + // Don't delete if focused on an input + if ((e.target as Element)?.tagName === 'INPUT' || (e.target as Element)?.tagName === 'TEXTAREA') return; + this.deleteNode(this.selectedNodeId); + } + } +} + +customElements.define('folk-automation-canvas', FolkAutomationCanvas); diff --git a/modules/rminders/components/folk-minders-app.ts b/modules/rminders/components/folk-minders-app.ts new file mode 100644 index 00000000..846dea34 --- /dev/null +++ b/modules/rminders/components/folk-minders-app.ts @@ -0,0 +1,1047 @@ +/** + * — reminder + job management UI. + * + * Job list with create/edit forms, execution log viewer, + * and manual run triggers. REST-based with offline-first Automerge sync. + */ + +import { mindersSchema, mindersDocId, type MindersDoc } from "../schemas"; +import type { DocumentId } from "../../../shared/local-first/document"; +import { TourEngine } from "../../../shared/tour-engine"; +import { ViewHistory } from "../../../shared/view-history.js"; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; + +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; +} + +interface ReminderData { + id: string; + title: string; + description: string; + remindAt: number; + allDay: boolean; + timezone: string; + notifyEmail: string | null; + notified: boolean; + completed: boolean; + sourceModule: string | null; + sourceLabel: string | null; + sourceColor: string | null; + cronExpression: string | null; + createdAt: number; + updatedAt: 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" }, + { value: "calendar-reminder", label: "Calendar Reminder" }, +]; + +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 FolkMindersApp extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private jobs: JobData[] = []; + private log: LogEntry[] = []; + private reminders: ReminderData[] = []; + private view: "jobs" | "log" | "form" | "reminders" | "reminder-form" = "jobs"; + private _history = new ViewHistory<"jobs" | "log" | "form" | "reminders" | "reminder-form">("jobs", "rminders"); + private editingJob: JobData | null = null; + private editingReminder: ReminderData | null = null; + private loading = false; + private runningJobId: string | null = null; + private _offlineUnsub: (() => void) | null = null; + private _subscribedDocIds: string[] = []; + private _stopPresence: (() => void) | null = null; + private _tour!: TourEngine; + private static readonly TOUR_STEPS = [ + { target: '[data-view="jobs"]', title: "Scheduled Jobs", message: "View and manage automated jobs that run on a cron schedule — email alerts, webhooks, and more.", advanceOnClick: true }, + { target: '[data-view="reminders"]', title: "Reminders", message: "Set personal reminders with optional email notifications and calendar sync.", advanceOnClick: true }, + { target: '[data-view="log"]', title: "Execution Log", message: "Review the history of all job runs with status, duration, and error details.", advanceOnClick: true }, + { target: '[data-action="create"]', title: "Create Job", message: "Create a new scheduled job with cron expressions and configurable actions.", advanceOnClick: false }, + ]; + + // Reminder form state + private rFormTitle = ""; + private rFormDescription = ""; + private rFormDate = ""; + private rFormTime = "09:00"; + private rFormAllDay = true; + private rFormEmail = ""; + private rFormSyncCal = true; + + // 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" }); + this._tour = new TourEngine( + this.shadow, + FolkMindersApp.TOUR_STEPS, + "rminders_tour_done", + () => this.shadow.host as HTMLElement, + ); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.subscribeOffline(); + this.loadJobs(); + if (!localStorage.getItem("rminders_tour_done")) { + setTimeout(() => this._tour.start(), 1200); + } + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rminders', context: 'Schedule' })); + window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener); + } + + disconnectedCallback() { + this._history.destroy(); + window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); + if (this._offlineUnsub) { + this._offlineUnsub(); + this._offlineUnsub = null; + } + this._stopPresence?.(); + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime) { + for (const id of this._subscribedDocIds) runtime.unsubscribe(id); + } + this._subscribedDocIds = []; + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + try { + const docId = mindersDocId(this.space) as DocumentId; + const doc = await runtime.subscribe(docId, mindersSchema); + this._subscribedDocIds.push(docId); + if (doc) this.renderFromDoc(doc as MindersDoc); + + this._offlineUnsub = runtime.onChange(docId, (doc: MindersDoc) => { + if (doc) this.renderFromDoc(doc); + }); + } catch { /* runtime unavailable */ } + } + + private renderFromDoc(doc: MindersDoc) { + if (doc.jobs && Object.keys(doc.jobs).length > 0) { + this.jobs = Object.values(doc.jobs).map((j) => ({ + id: j.id, + name: j.name, + description: j.description, + enabled: j.enabled, + cronExpression: j.cronExpression, + timezone: j.timezone, + actionType: j.actionType, + actionConfig: j.actionConfig as Record, + lastRunAt: j.lastRunAt, + lastRunStatus: j.lastRunStatus, + lastRunMessage: j.lastRunMessage, + nextRunAt: j.nextRunAt, + runCount: j.runCount, + createdBy: j.createdBy, + createdAt: j.createdAt, + updatedAt: j.updatedAt, + })); + } + + if (doc.reminders && Object.keys(doc.reminders).length > 0) { + this.reminders = Object.values(doc.reminders).map((r) => ({ + id: r.id, + title: r.title, + description: r.description, + remindAt: r.remindAt, + allDay: r.allDay, + timezone: r.timezone, + notifyEmail: r.notifyEmail, + notified: r.notified, + completed: r.completed, + sourceModule: r.sourceModule, + sourceLabel: r.sourceLabel, + sourceColor: r.sourceColor, + cronExpression: r.cronExpression, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); + } + + if (doc.log && doc.log.length > 0) { + this.log = doc.log.map((e) => ({ + id: e.id, + jobId: e.jobId, + status: e.status, + message: e.message, + durationMs: e.durationMs, + timestamp: e.timestamp, + })); + } + + this.loading = false; + this.render(); + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^(\/[^/]+)?\/rminders/); + 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 loadReminders() { + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/reminders`); + if (res.ok) { + const data = await res.json(); + this.reminders = data.results || []; + } + } catch { this.reminders = []; } + this.render(); + } + + private async completeReminder(id: string) { + const base = this.getApiBase(); + await fetch(`${base}/api/reminders/${id}/complete`, { method: "POST" }); + await this.loadReminders(); + } + + private async deleteReminder(id: string) { + if (!confirm("Delete this reminder?")) return; + const base = this.getApiBase(); + await fetch(`${base}/api/reminders/${id}`, { method: "DELETE" }); + await this.loadReminders(); + } + + private async snoozeReminder(id: string) { + const base = this.getApiBase(); + await fetch(`${base}/api/reminders/${id}/snooze`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hours: 24 }), + }); + await this.loadReminders(); + } + + private async submitReminderForm() { + const base = this.getApiBase(); + const remindAt = this.rFormAllDay + ? new Date(this.rFormDate + "T09:00:00").getTime() + : new Date(this.rFormDate + "T" + this.rFormTime).getTime(); + + const payload: Record = { + title: this.rFormTitle, + description: this.rFormDescription, + remindAt, + allDay: this.rFormAllDay, + notifyEmail: this.rFormEmail || null, + syncToCalendar: this.rFormSyncCal, + }; + + const isEdit = !!this.editingReminder; + const url = isEdit ? `${base}/api/reminders/${this.editingReminder!.id}` : `${base}/api/reminders`; + 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 reminder"); + return; + } + + this.view = "reminders"; + this.editingReminder = null; + await this.loadReminders(); + } + + private openCreateReminderForm() { + this.editingReminder = null; + this.rFormTitle = ""; + this.rFormDescription = ""; + this.rFormDate = new Date().toISOString().slice(0, 10); + this.rFormTime = "09:00"; + this.rFormAllDay = true; + this.rFormEmail = ""; + this.rFormSyncCal = true; + this._history.push(this.view); + this.view = "reminder-form"; + this._history.push("reminder-form"); + this.render(); + } + + private openEditReminderForm(r: ReminderData) { + this.editingReminder = r; + const d = new Date(r.remindAt); + this.rFormTitle = r.title; + this.rFormDescription = r.description; + this.rFormDate = d.toISOString().slice(0, 10); + this.rFormTime = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + this.rFormAllDay = r.allDay; + this.rFormEmail = r.notifyEmail || ""; + this.rFormSyncCal = true; + this._history.push(this.view); + this.view = "reminder-form"; + this._history.push("reminder-form"); + 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._history.push(this.view); + this.view = "form"; + this._history.push("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._history.push(this.view); + this.view = "form"; + this._history.push("form"); + this.render(); + } + + private _onViewRestored = (e: CustomEvent) => { + if (e.detail?.moduleId !== 'rminders') return; + this.view = e.detail.view; + if (e.detail.view === "reminders") this.loadReminders(); + else if (e.detail.view === "log") this.loadLog(); + else this.render(); + }; + + private goBack() { + const prev = this._history.back(); + if (!prev) return; + this.view = prev.view; + if (prev.view === "reminders") this.loadReminders(); + else if (prev.view === "log") this.loadLog(); + else 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(); + } else if (this.view === "reminders") { + content = this.renderReminderList(); + } else if (this.view === "reminder-form") { + content = this.renderReminderForm(); + } + + const activeTab = this.view === "form" ? "jobs" : this.view === "reminder-form" ? "reminders" : this.view; + let headerAction = ""; + if (this.view === "jobs") headerAction = ``; + else if (this.view === "reminders") headerAction = ``; + + this.shadow.innerHTML = ` + ${styles} +
+

rMinders

+
+ + + +
+ + ${headerAction} +
+ ${content} + `; + + this.attachListeners(); + this._tour.renderOverlay(); + } + + startTour() { + this._tour.start(); + } + + 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} +
OnJobScheduleTimezoneActionLast RunNext RunActions
+
+ `; + } + + 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 renderReminderList(): string { + if (this.reminders.length === 0) { + return `

No reminders yet.

`; + } + + const rows = this.reminders.map((r) => { + const dateStr = new Date(r.remindAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + const timeStr = r.allDay ? "All day" : new Date(r.remindAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + const statusBadge = r.completed + ? 'Done' + : r.notified + ? 'Sent' + : 'Pending'; + + return ` + +
+ +
+ ${this.esc(r.title)} + ${r.description ? `
${this.esc(r.description.slice(0, 60))}` : ""} +
+
+ + ${dateStr}
${timeStr} + ${r.sourceLabel ? `${this.esc(r.sourceLabel)}` : 'Free-form'} + ${statusBadge} + +
+ ${!r.completed ? `` : ""} + ${!r.completed ? `` : ""} + + +
+ + `; + }).join(""); + + return ` +
+ + + + + + + + + + + ${rows} +
ReminderDateSourceStatusActions
+
+ `; + } + + private renderReminderForm(): string { + const isEdit = !!this.editingReminder; + return ` +
+

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

+
+ + + + + ${!this.rFormAllDay ? `` : ""} + + +
+
+ + +
+
+ `; + } + + private attachListeners() { + this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + + // Tab switching + this.shadow.querySelectorAll("[data-view]").forEach((btn) => { + btn.addEventListener("click", () => { + const newView = btn.dataset.view as "jobs" | "log" | "reminders"; + this._history.push(this.view); + this.view = newView; + this._history.push(newView); + if (newView === "log") this.loadLog(); + else if (newView === "reminders") this.loadReminders(); + 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 / back + this.shadow.querySelector("[data-action='cancel']")?.addEventListener("click", () => { + this.goBack(); + }); + + // 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(); + + // Reminder: create button + this.shadow.querySelectorAll("[data-action='create-reminder']").forEach((btn) => { + btn.addEventListener("click", () => this.openCreateReminderForm()); + }); + + // Reminder: complete + this.shadow.querySelectorAll("[data-r-complete]").forEach((btn) => { + btn.addEventListener("click", () => this.completeReminder(btn.dataset.rComplete!)); + }); + + // Reminder: snooze + this.shadow.querySelectorAll("[data-r-snooze]").forEach((btn) => { + btn.addEventListener("click", () => this.snoozeReminder(btn.dataset.rSnooze!)); + }); + + // Reminder: edit + this.shadow.querySelectorAll("[data-r-edit]").forEach((btn) => { + btn.addEventListener("click", () => { + const r = this.reminders.find((rem) => rem.id === btn.dataset.rEdit); + if (r) this.openEditReminderForm(r); + }); + }); + + // Reminder: delete + this.shadow.querySelectorAll("[data-r-delete]").forEach((btn) => { + btn.addEventListener("click", () => this.deleteReminder(btn.dataset.rDelete!)); + }); + + // Reminder form: cancel / back + this.shadow.querySelector("[data-action='cancel-reminder']")?.addEventListener("click", () => { + this.goBack(); + }); + + // Reminder form: submit + this.shadow.querySelector("[data-action='submit-reminder']")?.addEventListener("click", () => { + this.collectReminderFormData(); + this.submitReminderForm(); + }); + + // Reminder form: all-day toggle re-renders to show/hide time field + this.shadow.querySelector("#rf-allday")?.addEventListener("change", (e) => { + this.collectReminderFormData(); + this.rFormAllDay = (e.target as HTMLInputElement).checked; + this.render(); + }); + } + + 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; + }); + } + + private collectReminderFormData() { + const getTitle = this.shadow.querySelector("#rf-title"); + const getDesc = this.shadow.querySelector("#rf-desc"); + const getDate = this.shadow.querySelector("#rf-date"); + const getTime = this.shadow.querySelector("#rf-time"); + const getAllDay = this.shadow.querySelector("#rf-allday"); + const getEmail = this.shadow.querySelector("#rf-email"); + const getSync = this.shadow.querySelector("#rf-sync"); + + if (getTitle) this.rFormTitle = getTitle.value; + if (getDesc) this.rFormDescription = getDesc.value; + if (getDate) this.rFormDate = getDate.value; + if (getTime) this.rFormTime = getTime.value; + if (getAllDay) this.rFormAllDay = getAllDay.checked; + if (getEmail) this.rFormEmail = getEmail.value; + if (getSync) this.rFormSyncCal = getSync.checked; + } +} + +customElements.define("folk-minders-app", FolkMindersApp); diff --git a/modules/rminders/components/folk-reminders-widget.ts b/modules/rminders/components/folk-reminders-widget.ts new file mode 100644 index 00000000..8d2dfa09 --- /dev/null +++ b/modules/rminders/components/folk-reminders-widget.ts @@ -0,0 +1,293 @@ +/** + * — lightweight sidebar widget for upcoming reminders. + * + * Fetches upcoming reminders from rMinders API, renders compact card list, + * supports quick actions (complete, snooze, delete), and accepts drops + * of cross-module items to create new reminders. + */ + +interface ReminderData { + id: string; + title: string; + description: string; + remindAt: number; + allDay: boolean; + completed: boolean; + notified: boolean; + sourceModule: string | null; + sourceLabel: string | null; + sourceColor: string | null; +} + +class FolkRemindersWidget extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private reminders: ReminderData[] = []; + private loading = false; + private showAddForm = false; + private formTitle = ""; + private formDate = ""; + private refreshTimer: ReturnType | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.loadReminders(); + // Auto-refresh every 5 minutes to pick up newly-due reminders + this.refreshTimer = setInterval(() => this.loadReminders(), 5 * 60_000); + } + + disconnectedCallback() { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^(\/[^/]+)/); + return match ? `${match[1]}/rminders` : "/rminders"; + } + + private async loadReminders() { + this.loading = true; + this.render(); + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/reminders?upcoming=7&completed=false`); + if (res.ok) { + const data = await res.json(); + this.reminders = data.results || []; + } + } catch { this.reminders = []; } + this.loading = false; + this.render(); + } + + private async completeReminder(id: string) { + const base = this.getApiBase(); + await fetch(`${base}/api/reminders/${id}/complete`, { method: "POST" }); + await this.loadReminders(); + } + + private async snoozeReminder(id: string) { + const base = this.getApiBase(); + await fetch(`${base}/api/reminders/${id}/snooze`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hours: 24 }), + }); + await this.loadReminders(); + } + + private async deleteReminder(id: string) { + if (!confirm("Delete this reminder?")) return; + const base = this.getApiBase(); + await fetch(`${base}/api/reminders/${id}`, { method: "DELETE" }); + await this.loadReminders(); + } + + private async createReminder(title: string, date: string) { + if (!title.trim() || !date) return; + const base = this.getApiBase(); + await fetch(`${base}/api/reminders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: title.trim(), + remindAt: new Date(date + "T09:00:00").getTime(), + allDay: true, + syncToCalendar: true, + }), + }); + this.showAddForm = false; + this.formTitle = ""; + this.formDate = ""; + await this.loadReminders(); + } + + private async handleDrop(e: DragEvent) { + e.preventDefault(); + (e.currentTarget as HTMLElement)?.classList.remove("widget-drop-active"); + + let title = ""; + let sourceModule: string | null = null; + let sourceEntityId: string | null = null; + let sourceLabel: string | null = null; + let sourceColor: string | null = null; + + const rspaceData = e.dataTransfer?.getData("application/rspace-item"); + if (rspaceData) { + try { + const parsed = JSON.parse(rspaceData); + title = parsed.title || ""; + sourceModule = parsed.module || null; + sourceEntityId = parsed.entityId || null; + sourceLabel = parsed.label || null; + sourceColor = parsed.color || null; + } catch { /* fall through */ } + } + + if (!title) { + title = e.dataTransfer?.getData("text/plain") || ""; + } + + if (!title.trim()) return; + + // Show add form pre-filled + this.formTitle = title.trim(); + this.formDate = new Date().toISOString().slice(0, 10); + this.showAddForm = true; + this.render(); + + // Store source info for when form is submitted + (this as any)._pendingSource = { sourceModule, sourceEntityId, sourceLabel, sourceColor }; + } + + private formatRelativeTime(ts: number): string { + const diff = ts - Date.now(); + if (diff < 0) return "overdue"; + if (diff < 3600000) return `in ${Math.floor(diff / 60000)}m`; + if (diff < 86400000) return `in ${Math.floor(diff / 3600000)}h`; + return `in ${Math.floor(diff / 86400000)}d`; + } + + 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 reminders...
`; + return; + } + + const cards = this.reminders.map(r => ` +
+
+
+
${this.esc(r.title)}
+
${this.formatRelativeTime(r.remindAt)}
+ ${r.sourceLabel ? `
${this.esc(r.sourceLabel)}
` : ""} +
+
+ + + +
+
+ `).join(""); + + const addForm = this.showAddForm ? ` +
+ + +
+ + +
+
+ ` : ""; + + this.shadow.innerHTML = ` + ${styles} +
+
+ 🔔 Reminders + +
+ ${addForm} + ${this.reminders.length > 0 ? cards : '
No upcoming reminders
'} +
+ `; + + this.attachListeners(); + } + + private attachListeners() { + // Add button + this.shadow.querySelector("[data-action='add']")?.addEventListener("click", () => { + this.showAddForm = !this.showAddForm; + this.formTitle = ""; + this.formDate = new Date().toISOString().slice(0, 10); + this.render(); + }); + + // Form submit + this.shadow.querySelector("[data-action='submit']")?.addEventListener("click", () => { + const title = (this.shadow.getElementById("rw-title") as HTMLInputElement)?.value || ""; + const date = (this.shadow.getElementById("rw-date") as HTMLInputElement)?.value || ""; + this.createReminder(title, date); + }); + + // Form cancel + this.shadow.querySelector("[data-action='cancel']")?.addEventListener("click", () => { + this.showAddForm = false; + this.render(); + }); + + // Complete + this.shadow.querySelectorAll("[data-complete]").forEach(btn => { + btn.addEventListener("click", () => this.completeReminder(btn.dataset.complete!)); + }); + + // Snooze + this.shadow.querySelectorAll("[data-snooze]").forEach(btn => { + btn.addEventListener("click", () => this.snoozeReminder(btn.dataset.snooze!)); + }); + + // Delete + this.shadow.querySelectorAll("[data-delete]").forEach(btn => { + btn.addEventListener("click", () => this.deleteReminder(btn.dataset.delete!)); + }); + + // Drop target on the whole widget + const root = this.shadow.getElementById("widget-root"); + if (root) { + root.addEventListener("dragover", (e) => { + e.preventDefault(); + root.classList.add("widget-drop-active"); + }); + root.addEventListener("dragleave", () => { + root.classList.remove("widget-drop-active"); + }); + root.addEventListener("drop", (e) => this.handleDrop(e as DragEvent)); + } + } +} + +customElements.define("folk-reminders-widget", FolkRemindersWidget); diff --git a/modules/rminders/components/minders.css b/modules/rminders/components/minders.css new file mode 100644 index 00000000..f7fef740 --- /dev/null +++ b/modules/rminders/components/minders.css @@ -0,0 +1,6 @@ +/* rMinders module — dark theme */ +folk-minders-app { + display: block; + min-height: 400px; + padding: 20px; +} diff --git a/modules/rminders/landing.ts b/modules/rminders/landing.ts new file mode 100644 index 00000000..dcf4369c --- /dev/null +++ b/modules/rminders/landing.ts @@ -0,0 +1,261 @@ +/** + * rMinders landing page — persistent reminders + job scheduling + automation canvas. + */ +export function renderLanding(): string { + return ` + +
+ + Persistent Scheduling + +

+ Automate (you)rSpace,
with (you)rMinders. +

+

+ Cron-powered job scheduling with email, webhooks, calendar events, and backlog briefings — all managed from within rSpace. +

+

+ rMinders replaces system-level crontabs with an in-process, persistent scheduler. + Jobs survive restarts, fire on a 60-second tick loop, and are fully configurable through the UI. +

+ +
+ + +
+
+
+
+
+ +
+

Cron Expressions

+

Standard cron syntax with timezone support. Schedule anything from every minute to once a year.

+
+
+
+ 📧 +
+

Email Actions

+

Send scheduled emails via SMTP — morning briefings, weekly digests, monthly audits.

+
+
+
+ 🔗 +
+

Webhook Actions

+

Fire HTTP requests on schedule — trigger builds, sync data, or ping external services.

+
+
+
+ 📋 +
+

Backlog Briefings

+

Automated task digests from your Backlog — morning, weekly, and monthly summaries delivered by email.

+
+
+
+
+ + +
+
+

Your Automations

+

+ Visual workflows built on the automation canvas +

+
+

Loading automations…

+
+
+
+ + + + +
+
+

How it works

+
+
+

Persistent Jobs

+

Jobs are stored in Automerge documents — they survive container restarts and server reboots. No more lost crontabs.

+
+
+

60-Second Tick Loop

+

A lightweight in-process loop checks every 60 seconds for due jobs. No external scheduler process needed.

+
+
+
+
+ + +
+
+

Ecosystem Integration

+
+
+

rCal

+

Create recurring calendar events automatically via the calendar-event action type.

+
+
+

rInbox

+

Schedule email delivery through shared SMTP infrastructure.

+
+
+

Backlog

+

Scan backlog tasks and generate automated priority briefings on any cadence.

+
+
+
+
+ + +
+

+ Stop managing crontabs. Start minding from rSpace. +

+

+ ← Back to rSpace +

+
+`; +} diff --git a/modules/rminders/local-first-client.ts b/modules/rminders/local-first-client.ts new file mode 100644 index 00000000..17283d85 --- /dev/null +++ b/modules/rminders/local-first-client.ts @@ -0,0 +1,158 @@ +/** + * rMinders Local-First Client + * + * Wraps the shared local-first stack for collaborative reminder + job management. + * Jobs, reminders, workflows, and execution logs sync in real-time. + */ + +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { mindersSchema, mindersDocId, MAX_LOG_ENTRIES } from './schemas'; +import type { MindersDoc, ScheduleJob, Reminder, Workflow, ExecutionLogEntry } from './schemas'; + +export class MindersLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + this.#documents.registerSchema(mindersSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('minders', 'jobs'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, mindersSchema, binary); + } + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[MindersClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = mindersDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, mindersSchema, binary) + : this.#documents.open(docId, mindersSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDoc(): MindersDoc | undefined { + return this.#documents.get(mindersDocId(this.#space) as DocumentId); + } + + onChange(cb: (doc: MindersDoc) => void): () => void { + return this.#sync.onChange(mindersDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + + // ── Job CRUD ── + + saveJob(job: ScheduleJob): void { + const docId = mindersDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Save job ${job.name}`, (d) => { + d.jobs[job.id] = job; + }); + } + + deleteJob(jobId: string): void { + const docId = mindersDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete job`, (d) => { + delete d.jobs[jobId]; + }); + } + + toggleJob(jobId: string, enabled: boolean): void { + const docId = mindersDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Toggle job`, (d) => { + if (d.jobs[jobId]) { + d.jobs[jobId].enabled = enabled; + d.jobs[jobId].updatedAt = Date.now(); + } + }); + } + + // ── Reminder CRUD ── + + saveReminder(reminder: Reminder): void { + const docId = mindersDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Save reminder ${reminder.title}`, (d) => { + d.reminders[reminder.id] = reminder; + }); + } + + deleteReminder(reminderId: string): void { + const docId = mindersDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete reminder`, (d) => { + delete d.reminders[reminderId]; + }); + } + + completeReminder(reminderId: string): void { + const docId = mindersDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Complete reminder`, (d) => { + if (d.reminders[reminderId]) { + d.reminders[reminderId].completed = true; + d.reminders[reminderId].updatedAt = Date.now(); + } + }); + } + + // ── Workflow CRUD ── + + saveWorkflow(workflow: Workflow): void { + const docId = mindersDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Save workflow ${workflow.name}`, (d) => { + d.workflows[workflow.id] = workflow; + }); + } + + deleteWorkflow(workflowId: string): void { + const docId = mindersDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete workflow`, (d) => { + delete d.workflows[workflowId]; + }); + } + + // ── Execution Log ── + + appendLogEntry(entry: ExecutionLogEntry): void { + const docId = mindersDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Log execution`, (d) => { + if (!d.log) d.log = [] as any; + d.log.push(entry); + // Trim to keep doc size manageable + while (d.log.length > MAX_LOG_ENTRIES) d.log.splice(0, 1); + }); + } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rminders/mod.ts b/modules/rminders/mod.ts new file mode 100644 index 00000000..3f3d5ee8 --- /dev/null +++ b/modules/rminders/mod.ts @@ -0,0 +1,2141 @@ +/** + * rMinders module — persistent cron-based job scheduling + reminders + workflow automation. + * + * Replaces system-level crontabs with an in-process scheduler. + * Jobs are stored in Automerge (survives restarts), evaluated on + * a 60-second tick loop, and can execute emails, webhooks, + * calendar events, broadcasts, or backlog briefings. + * + * All persistence uses Automerge documents via SyncServer. + */ + +import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; +import { createTransport, type Transporter } from "nodemailer"; +import { CronExpressionParser } from "cron-parser"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; +import { renderLanding } from "./landing"; +import type { SyncServer } from "../../server/local-first/sync-server"; +import { + mindersSchema, + mindersDocId, + MAX_LOG_ENTRIES, + MAX_REMINDERS, + MAX_WORKFLOW_LOG, +} from "./schemas"; +import type { + MindersDoc, + ScheduleJob, + ExecutionLogEntry, + ActionType, + Reminder, + Workflow, + WorkflowNode, + WorkflowEdge, + WorkflowLogEntry, +} from "./schemas"; +import { NODE_CATALOG } from "./schemas"; +import { calendarDocId } from "../rcal/schemas"; +import type { CalendarDoc, ScheduledItemMetadata } from "../rcal/schemas"; +import { boardDocId, createTaskItem } from "../rtasks/schemas"; +import type { BoardDoc } from "../rtasks/schemas"; + +let _syncServer: SyncServer | null = null; + +const routes = new Hono(); + +// ── SMTP transport (lazy init) ── + +let _smtpTransport: Transporter | null = null; + +function getSmtpTransport(): Transporter | null { + if (_smtpTransport) return _smtpTransport; + const host = process.env.SMTP_HOST || "mail.rmail.online"; + const isInternal = host.includes('mailcow') || host.includes('postfix'); + if (!process.env.SMTP_PASS && !isInternal) return null; + _smtpTransport = createTransport({ + host, + port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587), + secure: !isInternal && Number(process.env.SMTP_PORT) === 465, + ...(isInternal ? {} : { + auth: { + user: process.env.SMTP_USER || "noreply@rmail.online", + pass: process.env.SMTP_PASS!, + }, + }), + tls: { rejectUnauthorized: false }, + }); + return _smtpTransport; +} + +// ── Local-first helpers ── + +function ensureDoc(space: string): MindersDoc { + const docId = mindersDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change( + Automerge.init(), + "init schedule", + (d) => { + const init = mindersSchema.init(); + d.meta = init.meta; + d.meta.spaceSlug = space; + d.jobs = {}; + d.reminders = {}; + d.workflows = {}; + d.log = []; + }, + ); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +// ── Cron helpers ── + +function computeNextRun(cronExpression: string, timezone: string): number | null { + try { + const interval = CronExpressionParser.parse(cronExpression, { + currentDate: new Date(), + tz: timezone, + }); + return interval.next().toDate().getTime(); + } catch { + return null; + } +} + +function cronToHuman(expr: string): string { + const parts = expr.split(/\s+/); + if (parts.length !== 5) return expr; + const [min, hour, dom, mon, dow] = parts; + + const dowNames: Record = { + "0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed", + "4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun", + "1-5": "weekdays", "0,6": "weekends", + }; + + if (min === "0" && hour !== "*" && dom === "*" && mon === "*" && dow === "*") + return `Daily at ${hour}:00`; + if (min === "0" && hour !== "*" && dom === "*" && mon === "*" && dow === "1-5") + return `Weekdays at ${hour}:00`; + if (min === "0" && hour !== "*" && dom === "*" && mon === "*" && dow !== "*") + return `${dowNames[dow] || dow} at ${hour}:00`; + if (min === "0" && hour !== "*" && dom !== "*" && mon === "*" && dow === "*") + return `Monthly on day ${dom} at ${hour}:00`; + if (min === "*" && hour === "*" && dom === "*" && mon === "*" && dow === "*") + return "Every minute"; + if (min.startsWith("*/")) + return `Every ${min.slice(2)} minutes`; + return expr; +} + +// ── Template helpers ── + +function renderTemplate(template: string, vars: Record): string { + let result = template; + for (const [key, value] of Object.entries(vars)) { + result = result.replaceAll(`{{${key}}}`, value); + } + return result; +} + +// ── Action executors ── + +async function executeEmail( + job: ScheduleJob, +): Promise<{ success: boolean; message: string }> { + const transport = getSmtpTransport(); + if (!transport) + return { success: false, message: "SMTP not configured (SMTP_PASS missing)" }; + + const config = job.actionConfig as { + to?: string; + subject?: string; + bodyTemplate?: string; + }; + if (!config.to) + return { success: false, message: "No recipient (to) configured" }; + + const vars = { + date: new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }), + jobName: job.name, + timestamp: new Date().toISOString(), + }; + + const subject = renderTemplate(config.subject || `[rMinders] ${job.name}`, vars); + const html = renderTemplate(config.bodyTemplate || `

Scheduled job ${job.name} executed at ${vars.date}.

`, vars); + + await transport.sendMail({ + from: process.env.SMTP_FROM || "rMinders ", + to: config.to, + subject, + html, + }); + + return { success: true, message: `Email sent to ${config.to}` }; +} + +async function executeWebhook( + job: ScheduleJob, +): Promise<{ success: boolean; message: string }> { + const config = job.actionConfig as { + url?: string; + method?: string; + headers?: Record; + bodyTemplate?: string; + }; + if (!config.url) + return { success: false, message: "No webhook URL configured" }; + + const vars = { + date: new Date().toISOString(), + jobName: job.name, + timestamp: new Date().toISOString(), + }; + + const method = (config.method || "POST").toUpperCase(); + const headers: Record = { + "Content-Type": "application/json", + ...config.headers, + }; + + const body = method !== "GET" + ? renderTemplate(config.bodyTemplate || JSON.stringify({ job: job.name, timestamp: vars.date }), vars) + : undefined; + + const res = await fetch(config.url, { method, headers, body }); + if (!res.ok) + return { success: false, message: `Webhook ${res.status}: ${await res.text().catch(() => "")}` }; + + return { success: true, message: `Webhook ${method} ${config.url} → ${res.status}` }; +} + +async function executeCalendarEvent( + job: ScheduleJob, + space: string, +): Promise<{ success: boolean; message: string }> { + if (!_syncServer) + return { success: false, message: "SyncServer not available" }; + + const config = job.actionConfig as { + title?: string; + duration?: number; + sourceId?: string; + }; + + const calDocId = calendarDocId(space); + const calDoc = _syncServer.getDoc(calDocId); + if (!calDoc) + return { success: false, message: `Calendar doc not found for space ${space}` }; + + const eventId = crypto.randomUUID(); + const now = Date.now(); + const durationMs = (config.duration || 60) * 60 * 1000; + + _syncServer.changeDoc(calDocId, `rMinders: create event for ${job.name}`, (d) => { + d.events[eventId] = { + id: eventId, + title: config.title || job.name, + description: `Auto-created by rMinders job: ${job.name}`, + startTime: now, + endTime: now + durationMs, + allDay: false, + timezone: job.timezone || "UTC", + rrule: null, + status: null, + visibility: null, + sourceId: config.sourceId || null, + sourceName: null, + sourceType: null, + sourceColor: null, + locationId: null, + locationName: null, + coordinates: null, + locationGranularity: null, + locationLat: null, + locationLng: null, + locationBreadcrumb: null, + bookingStatus: null, + isVirtual: false, + virtualUrl: null, + virtualPlatform: null, + rToolSource: "rMinders", + rToolEntityId: job.id, + attendees: [], + attendeeCount: 0, + tags: null, + metadata: null, + likelihood: null, + createdAt: now, + updatedAt: now, + }; + }); + + return { success: true, message: `Calendar event '${config.title || job.name}' created (${eventId})` }; +} + +async function executeBroadcast( + job: ScheduleJob, +): Promise<{ success: boolean; message: string }> { + const config = job.actionConfig as { + channel?: string; + message?: string; + }; + + // Broadcast via SyncServer's WebSocket connections is not directly accessible + // from module code. For now, log the intent. Future: expose ws broadcast on SyncServer. + const msg = config.message || `Scheduled broadcast from ${job.name}`; + console.log(`[Minders] Broadcast (${config.channel || "default"}): ${msg}`); + return { success: true, message: `Broadcast sent: ${msg}` }; +} + +async function executeBacklogBriefing( + job: ScheduleJob, +): Promise<{ success: boolean; message: string }> { + const config = job.actionConfig as { + mode?: "morning" | "weekly" | "monthly"; + scanPaths?: string[]; + to?: string; + }; + + const transport = getSmtpTransport(); + if (!transport) + return { success: false, message: "SMTP not configured (SMTP_PASS missing)" }; + if (!config.to) + return { success: false, message: "No recipient (to) configured" }; + + const mode = config.mode || "morning"; + const scanPaths = config.scanPaths || ["/data/communities/*/backlog/tasks/"]; + const now = new Date(); + const dateStr = now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }); + + // Scan for backlog task files + const { readdir, readFile, stat } = await import("node:fs/promises"); + const { join, basename } = await import("node:path"); + const { Glob } = await import("bun"); + + interface TaskInfo { + file: string; + title: string; + priority: string; + status: string; + updatedAt: Date | null; + staleDays: number; + } + + const tasks: TaskInfo[] = []; + + for (const pattern of scanPaths) { + try { + const glob = new Glob(pattern.endsWith("/") ? pattern + "*.md" : pattern); + for await (const filePath of glob.scan()) { + try { + const content = await readFile(filePath, "utf-8"); + const fstat = await stat(filePath); + + // Parse YAML frontmatter + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + let title = basename(filePath, ".md").replace(/-/g, " "); + let priority = "medium"; + let status = "open"; + + if (fmMatch) { + const fm = fmMatch[1]; + const titleMatch = fm.match(/^title:\s*(.+)$/m); + const prioMatch = fm.match(/^priority:\s*(.+)$/m); + const statusMatch = fm.match(/^status:\s*(.+)$/m); + if (titleMatch) title = titleMatch[1].replace(/^["']|["']$/g, ""); + if (prioMatch) priority = prioMatch[1].trim().toLowerCase(); + if (statusMatch) status = statusMatch[1].trim().toLowerCase(); + } + + const staleDays = Math.floor( + (now.getTime() - fstat.mtime.getTime()) / (1000 * 60 * 60 * 24), + ); + + tasks.push({ file: filePath, title, priority, status, updatedAt: fstat.mtime, staleDays }); + } catch { + // Skip unreadable files + } + } + } catch { + // Glob pattern didn't match or dir doesn't exist + } + } + + // Filter and sort based on mode + let filtered = tasks.filter((t) => t.status !== "done" && t.status !== "closed"); + let subject: string; + let heading: string; + + switch (mode) { + case "morning": + // High/urgent priority + recently updated + filtered = filtered + .filter((t) => t.priority === "high" || t.priority === "urgent" || t.staleDays < 3) + .sort((a, b) => { + const priOrder: Record = { urgent: 0, high: 1, medium: 2, low: 3 }; + return (priOrder[a.priority] ?? 2) - (priOrder[b.priority] ?? 2); + }); + subject = `Morning Briefing — ${dateStr}`; + heading = "Good morning! Here's your task briefing:"; + break; + case "weekly": + // All open tasks sorted by priority then staleness + filtered.sort((a, b) => { + const priOrder: Record = { urgent: 0, high: 1, medium: 2, low: 3 }; + const pDiff = (priOrder[a.priority] ?? 2) - (priOrder[b.priority] ?? 2); + return pDiff !== 0 ? pDiff : b.staleDays - a.staleDays; + }); + subject = `Weekly Backlog Review — ${dateStr}`; + heading = "Weekly review of all open tasks:"; + break; + case "monthly": + // Focus on stale items (> 14 days untouched) + filtered = filtered + .filter((t) => t.staleDays > 14) + .sort((a, b) => b.staleDays - a.staleDays); + subject = `Monthly Backlog Audit — ${dateStr}`; + heading = "Monthly audit — these tasks haven't been touched in 14+ days:"; + break; + } + + // Build HTML email + const taskRows = filtered.length > 0 + ? filtered + .slice(0, 50) + .map((t) => { + const prioColor: Record = { + urgent: "#ef4444", high: "#f97316", medium: "#f59e0b", low: "#6b7280", + }; + return ` + + ${t.priority} + + ${t.title} + ${t.status} + ${t.staleDays}d ago + `; + }) + .join("\n") + : `No tasks match this filter.`; + + const html = ` +
+

${heading}

+

${dateStr} • ${filtered.length} task${filtered.length !== 1 ? "s" : ""}

+ + + + + + + + + + ${taskRows} +
PriorityTaskStatusLast Update
+

+ Sent by rMinders • Manage Schedules +

+
+ `; + + await transport.sendMail({ + from: process.env.SMTP_FROM || "rMinders ", + to: config.to, + subject: `[rMinders] ${subject}`, + html, + }); + + return { success: true, message: `${mode} briefing sent to ${config.to} (${filtered.length} tasks)` }; +} + +async function executeCalendarReminder( + job: ScheduleJob, + space: string, +): Promise<{ success: boolean; message: string }> { + if (!_syncServer) + return { success: false, message: "SyncServer not available" }; + + const transport = getSmtpTransport(); + if (!transport) + return { success: false, message: "SMTP not configured (SMTP_PASS missing)" }; + + const config = job.actionConfig as { to?: string }; + if (!config.to) + return { success: false, message: "No recipient (to) configured" }; + + // Load the calendar doc for this space + const calDocId = calendarDocId(space); + const calDoc = _syncServer.getDoc(calDocId); + if (!calDoc) + return { success: false, message: `Calendar doc not found for space ${space}` }; + + // Find scheduled items due today that haven't been reminded yet + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const todayEnd = todayStart + 86400000; + + const dueItems = Object.values(calDoc.events).filter((ev) => { + const meta = ev.metadata as ScheduledItemMetadata | null; + return meta?.isScheduledItem === true + && !meta.reminderSent + && ev.startTime >= todayStart + && ev.startTime < todayEnd; + }); + + if (dueItems.length === 0) + return { success: true, message: "No scheduled items due today" }; + + // Render email with all due items + const dateStr = now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }); + const itemRows = dueItems.map((ev) => { + const meta = ev.metadata as ScheduledItemMetadata; + const preview = meta.itemPreview; + const prov = meta.provenance; + const thumbHtml = preview.thumbnailUrl + ? `thumbnail` + : ""; + const canvasLink = preview.canvasUrl + ? `Open in Canvas` + : ""; + return ` + +
${ev.title}
+
${preview.textPreview}
+
+ Source: ${prov.sourceType} in ${prov.sourceSpace} + ${prov.rid ? ` • RID: ${prov.rid}` : ""} +
+ ${thumbHtml} +
${canvasLink}
+ + `; + }).join("\n"); + + const html = ` +
+

Scheduled Knowledge Reminders

+

${dateStr} • ${dueItems.length} item${dueItems.length !== 1 ? "s" : ""}

+ + ${itemRows} +
+

+ Sent by rMinders • View Calendar +

+
+ `; + + await transport.sendMail({ + from: process.env.SMTP_FROM || "rMinders ", + to: config.to, + subject: `[rSpace] ${dueItems.length} scheduled item${dueItems.length !== 1 ? "s" : ""} for ${dateStr}`, + html, + }); + + // Mark all sent items as reminded + _syncServer.changeDoc(calDocId, `mark ${dueItems.length} reminders sent`, (d) => { + for (const item of dueItems) { + const ev = d.events[item.id]; + if (!ev) continue; + const meta = ev.metadata as ScheduledItemMetadata; + meta.reminderSent = true; + meta.reminderSentAt = Date.now(); + ev.updatedAt = Date.now(); + } + }); + + return { success: true, message: `Calendar reminder sent to ${config.to} (${dueItems.length} items)` }; +} + +// ── Unified executor ── + +async function executeJob( + job: ScheduleJob, + space: string, +): Promise<{ success: boolean; message: string }> { + switch (job.actionType) { + case "email": + return executeEmail(job); + case "webhook": + return executeWebhook(job); + case "calendar-event": + return executeCalendarEvent(job, space); + case "broadcast": + return executeBroadcast(job); + case "backlog-briefing": + return executeBacklogBriefing(job); + case "calendar-reminder": + return executeCalendarReminder(job, space); + default: + return { success: false, message: `Unknown action type: ${job.actionType}` }; + } +} + +// ── Tick loop ── + +const TICK_INTERVAL = 60_000; + +function startTickLoop() { + console.log("[Minders] Tick loop started — checking every 60s"); + + const tick = async () => { + if (!_syncServer) return; + + const now = Date.now(); + + // Iterate all known schedule docs + // Convention: check the "demo" space and any spaces that have schedule docs + const spaceSlugs = new Set(); + spaceSlugs.add("demo"); + + // Also scan for any schedule docs already loaded + const allDocs = _syncServer.listDocs(); + for (const docId of allDocs) { + const match = docId.match(/^(.+):minders:jobs$/); + if (match) spaceSlugs.add(match[1]); + } + + for (const space of spaceSlugs) { + try { + const docId = mindersDocId(space); + const doc = _syncServer.getDoc(docId); + if (!doc) continue; + + const dueJobs = Object.values(doc.jobs).filter( + (j) => j.enabled && j.nextRunAt && j.nextRunAt <= now, + ); + + for (const job of dueJobs) { + const startMs = Date.now(); + let result: { success: boolean; message: string }; + + try { + result = await executeJob(job, space); + } catch (e: any) { + result = { success: false, message: e.message || String(e) }; + } + + const durationMs = Date.now() - startMs; + const logEntry: ExecutionLogEntry = { + id: crypto.randomUUID(), + jobId: job.id, + status: result.success ? "success" : "error", + message: result.message, + durationMs, + timestamp: Date.now(), + }; + + console.log( + `[Minders] ${result.success ? "OK" : "ERR"} ${job.name} (${durationMs}ms): ${result.message}`, + ); + + // Update job state + append log + _syncServer.changeDoc(docId, `run job ${job.id}`, (d) => { + const j = d.jobs[job.id]; + if (!j) return; + j.lastRunAt = Date.now(); + j.lastRunStatus = result.success ? "success" : "error"; + j.lastRunMessage = result.message; + j.runCount = (j.runCount || 0) + 1; + j.nextRunAt = computeNextRun(j.cronExpression, j.timezone) ?? null; + + // Append log entry, trim to max + d.log.push(logEntry); + while (d.log.length > MAX_LOG_ENTRIES) { + d.log.splice(0, 1); + } + }); + } + // ── Process due reminders ── + const dueReminders = Object.values(doc.reminders || {}).filter( + (r) => !r.notified && !r.completed && r.remindAt <= now && r.notifyEmail, + ); + + for (const reminder of dueReminders) { + try { + const result = await executeReminderEmail(reminder, space); + console.log( + `[Minders] Reminder ${result.success ? "OK" : "ERR"} "${reminder.title}": ${result.message}`, + ); + + _syncServer.changeDoc(docId, `notify reminder ${reminder.id}`, (d) => { + const r = d.reminders[reminder.id]; + if (!r) return; + r.notified = true; + r.updatedAt = Date.now(); + + // Handle recurring reminders + if (r.cronExpression) { + const nextRun = computeNextRun(r.cronExpression, r.timezone); + if (nextRun) { + r.remindAt = nextRun; + r.notified = false; + } + } + }); + } catch (e) { + console.error(`[Minders] Reminder email error for "${reminder.title}":`, e); + } + } + + // ── Process due automation workflows ── + const workflows = Object.values(doc.workflows || {}); + for (const wf of workflows) { + if (!wf.enabled) continue; + const cronNodes = wf.nodes.filter(n => n.type === "trigger-cron"); + for (const cronNode of cronNodes) { + const expr = String(cronNode.config.cronExpression || ""); + const tz = String(cronNode.config.timezone || "UTC"); + if (!expr) continue; + try { + const interval = CronExpressionParser.parse(expr, { + currentDate: new Date(now - TICK_INTERVAL), + tz, + }); + const nextDate = interval.next().toDate(); + if (nextDate.getTime() <= now) { + console.log(`[Minders] Running cron workflow "${wf.name}" for space ${space}`); + const results = await executeWorkflow(wf, space); + const allOk = results.every(r => r.status !== "error"); + _syncServer.changeDoc(docId, `tick workflow ${wf.id}`, (d) => { + const w = d.workflows[wf.id]; + if (!w) return; + w.lastRunAt = Date.now(); + w.lastRunStatus = allOk ? "success" : "error"; + w.runCount = (w.runCount || 0) + 1; + w.updatedAt = Date.now(); + }); + appendWorkflowLog(space, wf.id, results, "cron"); + } + } catch { /* invalid cron — skip */ } + } + } + } catch (e) { + console.error(`[Minders] Tick error for space ${space}:`, e); + } + } + }; + + setTimeout(tick, 10_000); // First tick after 10s + setInterval(tick, TICK_INTERVAL); +} + +// ── Seed default jobs ── + +const SEED_JOBS: Omit[] = [ + { + id: "backlog-morning", + name: "Morning Backlog Briefing", + description: "Weekday morning digest of high-priority and recently-updated tasks.", + enabled: true, + cronExpression: "0 14 * * 1-5", + timezone: "America/Vancouver", + actionType: "backlog-briefing", + actionConfig: { mode: "morning", to: "jeff@jeffemmett.com" }, + createdBy: "system", + }, + { + id: "backlog-weekly", + name: "Weekly Backlog Review", + description: "Friday afternoon review of all open tasks sorted by priority and staleness.", + enabled: true, + cronExpression: "0 22 * * 5", + timezone: "America/Vancouver", + actionType: "backlog-briefing", + actionConfig: { mode: "weekly", to: "jeff@jeffemmett.com" }, + createdBy: "system", + }, + { + id: "backlog-monthly", + name: "Monthly Backlog Audit", + description: "First of the month audit of stale tasks (14+ days untouched).", + enabled: true, + cronExpression: "0 14 1 * *", + timezone: "America/Vancouver", + actionType: "backlog-briefing", + actionConfig: { mode: "monthly", to: "jeff@jeffemmett.com" }, + createdBy: "system", + }, + { + id: "calendar-reminder-daily", + name: "Daily Calendar Reminders", + description: "Sends email reminders for knowledge items scheduled on today's date.", + enabled: true, + cronExpression: "0 14 * * *", + timezone: "America/Vancouver", + actionType: "calendar-reminder", + actionConfig: { to: "jeff@jeffemmett.com" }, + createdBy: "system", + }, +]; + +function seedDefaultJobs(space: string) { + const docId = mindersDocId(space); + const doc = ensureDoc(space); + + if (Object.keys(doc.jobs).length > 0) return; + + const now = Date.now(); + _syncServer!.changeDoc(docId, "seed default jobs", (d) => { + for (const seed of SEED_JOBS) { + d.jobs[seed.id] = { + ...seed, + lastRunAt: null, + lastRunStatus: null, + lastRunMessage: "", + nextRunAt: computeNextRun(seed.cronExpression, seed.timezone), + runCount: 0, + createdAt: now, + updatedAt: now, + }; + } + }); + + console.log(`[Minders] Seeded ${SEED_JOBS.length} default jobs for space "${space}"`); +} + +// ── API routes ── + +// GET / — serve schedule UI +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + return c.html( + renderShell({ + title: `${space} — Minders | rSpace`, + moduleId: "rminders", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + styles: ``, + }), + ); +}); + +// GET /api/jobs — list all jobs +routes.get("/api/jobs", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + const jobs = Object.values(doc.jobs).map((j) => ({ + ...j, + cronHuman: cronToHuman(j.cronExpression), + })); + jobs.sort((a, b) => a.name.localeCompare(b.name)); + return c.json({ count: jobs.length, results: jobs }); +}); + +// POST /api/jobs — create a new job +routes.post("/api/jobs", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + + const { name, description, cronExpression, timezone, actionType, actionConfig, enabled } = body; + if (!name?.trim() || !cronExpression || !actionType) + return c.json({ error: "name, cronExpression, and actionType required" }, 400); + + // Validate cron expression + try { + CronExpressionParser.parse(cronExpression); + } catch { + return c.json({ error: "Invalid cron expression" }, 400); + } + + const docId = mindersDocId(dataSpace); + ensureDoc(dataSpace); + const jobId = crypto.randomUUID(); + const now = Date.now(); + const tz = timezone || "UTC"; + + _syncServer!.changeDoc(docId, `create job ${jobId}`, (d) => { + d.jobs[jobId] = { + id: jobId, + name: name.trim(), + description: description || "", + enabled: enabled !== false, + cronExpression, + timezone: tz, + actionType, + actionConfig: actionConfig || {}, + lastRunAt: null, + lastRunStatus: null, + lastRunMessage: "", + nextRunAt: computeNextRun(cronExpression, tz), + runCount: 0, + createdBy: "user", + createdAt: now, + updatedAt: now, + }; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.jobs[jobId], 201); +}); + +// GET /api/jobs/:id +routes.get("/api/jobs/:id", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const doc = ensureDoc(dataSpace); + + const job = doc.jobs[id]; + if (!job) return c.json({ error: "Job not found" }, 404); + return c.json({ ...job, cronHuman: cronToHuman(job.cronExpression) }); +}); + +// PUT /api/jobs/:id — update a job +routes.put("/api/jobs/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + const docId = mindersDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404); + + // Validate cron if provided + if (body.cronExpression) { + try { + CronExpressionParser.parse(body.cronExpression); + } catch { + return c.json({ error: "Invalid cron expression" }, 400); + } + } + + _syncServer!.changeDoc(docId, `update job ${id}`, (d) => { + const j = d.jobs[id]; + if (!j) return; + if (body.name !== undefined) j.name = body.name; + if (body.description !== undefined) j.description = body.description; + if (body.enabled !== undefined) j.enabled = body.enabled; + if (body.cronExpression !== undefined) { + j.cronExpression = body.cronExpression; + j.nextRunAt = computeNextRun(body.cronExpression, body.timezone || j.timezone); + } + if (body.timezone !== undefined) { + j.timezone = body.timezone; + j.nextRunAt = computeNextRun(j.cronExpression, body.timezone); + } + if (body.actionType !== undefined) j.actionType = body.actionType; + if (body.actionConfig !== undefined) j.actionConfig = body.actionConfig; + j.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.jobs[id]); +}); + +// DELETE /api/jobs/:id +routes.delete("/api/jobs/:id", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = mindersDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404); + + _syncServer!.changeDoc(docId, `delete job ${id}`, (d) => { + delete d.jobs[id]; + }); + + return c.json({ ok: true }); +}); + +// POST /api/jobs/:id/run — manually trigger a job +routes.post("/api/jobs/:id/run", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = mindersDocId(dataSpace); + const doc = ensureDoc(dataSpace); + const job = doc.jobs[id]; + if (!job) return c.json({ error: "Job not found" }, 404); + + const startMs = Date.now(); + let result: { success: boolean; message: string }; + + try { + result = await executeJob(job, dataSpace); + } catch (e: any) { + result = { success: false, message: e.message || String(e) }; + } + + const durationMs = Date.now() - startMs; + const logEntry: ExecutionLogEntry = { + id: crypto.randomUUID(), + jobId: job.id, + status: result.success ? "success" : "error", + message: result.message, + durationMs, + timestamp: Date.now(), + }; + + _syncServer!.changeDoc(docId, `manual run ${id}`, (d) => { + const j = d.jobs[id]; + if (j) { + j.lastRunAt = Date.now(); + j.lastRunStatus = result.success ? "success" : "error"; + j.lastRunMessage = result.message; + j.runCount = (j.runCount || 0) + 1; + } + d.log.push(logEntry); + while (d.log.length > MAX_LOG_ENTRIES) { + d.log.splice(0, 1); + } + }); + + return c.json({ ...result, durationMs }); +}); + +// GET /api/log — execution log +routes.get("/api/log", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + const log = [...doc.log].reverse(); // newest first + return c.json({ count: log.length, results: log }); +}); + +// GET /api/log/:jobId — execution log filtered by job +routes.get("/api/log/:jobId", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const jobId = c.req.param("jobId"); + const doc = ensureDoc(dataSpace); + const log = doc.log.filter((e) => e.jobId === jobId).reverse(); + return c.json({ count: log.length, results: log }); +}); + +// ── Reminder helpers ── + +function ensureRemindersCalendarSource(space: string): string { + const calDocId = calendarDocId(space); + const calDoc = _syncServer!.getDoc(calDocId); + if (!calDoc) return ""; + + // Check if "Reminders" source already exists + const existing = Object.values(calDoc.sources).find( + (s) => s.name === "Reminders" && s.sourceType === "rMinders", + ); + if (existing) return existing.id; + + const sourceId = crypto.randomUUID(); + const now = Date.now(); + _syncServer!.changeDoc(calDocId, "create Reminders calendar source", (d) => { + d.sources[sourceId] = { + id: sourceId, + name: "Reminders", + sourceType: "rMinders", + url: null, + color: "#f59e0b", + isActive: true, + isVisible: true, + syncIntervalMinutes: null, + lastSyncedAt: now, + ownerId: null, + createdAt: now, + }; + }); + return sourceId; +} + +function syncReminderToCalendar(reminder: Reminder, space: string): string | null { + if (!_syncServer) return null; + + const calDocId = calendarDocId(space); + const calDoc = _syncServer.getDoc(calDocId); + if (!calDoc) return null; + + const sourceId = ensureRemindersCalendarSource(space); + const eventId = crypto.randomUUID(); + const now = Date.now(); + const duration = reminder.allDay ? 86400000 : 3600000; + + _syncServer.changeDoc(calDocId, `sync reminder ${reminder.id} to calendar`, (d) => { + d.events[eventId] = { + id: eventId, + title: reminder.title, + description: reminder.description, + startTime: reminder.remindAt, + endTime: reminder.remindAt + duration, + allDay: reminder.allDay, + timezone: reminder.timezone || "UTC", + rrule: null, + status: null, + visibility: null, + sourceId, + sourceName: "Reminders", + sourceType: "rMinders", + sourceColor: reminder.sourceColor || "#f59e0b", + locationId: null, + locationName: null, + coordinates: null, + locationGranularity: null, + locationLat: null, + locationLng: null, + locationBreadcrumb: null, + bookingStatus: null, + isVirtual: false, + virtualUrl: null, + virtualPlatform: null, + rToolSource: "rMinders", + rToolEntityId: reminder.id, + attendees: [], + attendeeCount: 0, + tags: null, + metadata: null, + likelihood: null, + createdAt: now, + updatedAt: now, + }; + }); + + return eventId; +} + +function deleteCalendarEvent(space: string, eventId: string) { + if (!_syncServer) return; + const calDocId = calendarDocId(space); + const calDoc = _syncServer.getDoc(calDocId); + if (!calDoc || !calDoc.events[eventId]) return; + + _syncServer.changeDoc(calDocId, `delete reminder calendar event ${eventId}`, (d) => { + delete d.events[eventId]; + }); +} + +// ── Reminder email executor ── + +async function executeReminderEmail( + reminder: Reminder, + space: string, +): Promise<{ success: boolean; message: string }> { + const transport = getSmtpTransport(); + if (!transport) + return { success: false, message: "SMTP not configured (SMTP_PASS missing)" }; + if (!reminder.notifyEmail) + return { success: false, message: "No email address on reminder" }; + + const dateStr = new Date(reminder.remindAt).toLocaleDateString("en-US", { + weekday: "long", year: "numeric", month: "long", day: "numeric", + hour: "numeric", minute: "2-digit", + }); + + const sourceInfo = reminder.sourceModule + ? `

Source: ${reminder.sourceLabel || reminder.sourceModule}

` + : ""; + + const html = ` +
+

🔔 Reminder: ${reminder.title}

+

${dateStr}

+ ${reminder.description ? `

${reminder.description}

` : ""} + ${sourceInfo} +

+ Sent by rMinders • Manage Reminders +

+
+ `; + + await transport.sendMail({ + from: process.env.SMTP_FROM || "rMinders ", + to: reminder.notifyEmail, + subject: `[Reminder] ${reminder.title}`, + html, + }); + + return { success: true, message: `Reminder email sent to ${reminder.notifyEmail}` }; +} + +// ── Reminder API routes ── + +// GET /api/reminders — list reminders +routes.get("/api/reminders", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + + let reminders = Object.values(doc.reminders); + + // Query filters + const upcoming = c.req.query("upcoming"); + const completed = c.req.query("completed"); + + if (completed === "false") { + reminders = reminders.filter((r) => !r.completed); + } else if (completed === "true") { + reminders = reminders.filter((r) => r.completed); + } + + if (upcoming) { + const days = parseInt(upcoming) || 7; + const now = Date.now(); + const cutoff = now + days * 86400000; + reminders = reminders.filter((r) => r.remindAt >= now && r.remindAt <= cutoff); + } + + reminders.sort((a, b) => a.remindAt - b.remindAt); + return c.json({ count: reminders.length, results: reminders }); +}); + +// POST /api/reminders — create a reminder +routes.post("/api/reminders", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + + const { title, description, remindAt, allDay, timezone, notifyEmail, syncToCalendar, cronExpression } = body; + if (!title?.trim() || !remindAt) + return c.json({ error: "title and remindAt required" }, 400); + + const docId = mindersDocId(dataSpace); + const doc = ensureDoc(dataSpace); + + if (Object.keys(doc.reminders).length >= MAX_REMINDERS) + return c.json({ error: `Maximum ${MAX_REMINDERS} reminders reached` }, 400); + + const reminderId = crypto.randomUUID(); + const now = Date.now(); + + const reminder: Reminder = { + id: reminderId, + title: title.trim(), + description: description || "", + remindAt: typeof remindAt === "number" ? remindAt : new Date(remindAt).getTime(), + allDay: allDay || false, + timezone: timezone || "UTC", + notifyEmail: notifyEmail || null, + notified: false, + completed: false, + sourceModule: body.sourceModule || null, + sourceEntityId: body.sourceEntityId || null, + sourceLabel: body.sourceLabel || null, + sourceColor: body.sourceColor || null, + cronExpression: cronExpression || null, + calendarEventId: null, + createdBy: "user", + createdAt: now, + updatedAt: now, + }; + + // Sync to calendar if requested + if (syncToCalendar) { + const eventId = syncReminderToCalendar(reminder, dataSpace); + if (eventId) reminder.calendarEventId = eventId; + } + + _syncServer!.changeDoc(docId, `create reminder ${reminderId}`, (d) => { + d.reminders[reminderId] = reminder; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.reminders[reminderId], 201); +}); + +// GET /api/reminders/:id — get single reminder +routes.get("/api/reminders/:id", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const doc = ensureDoc(dataSpace); + + const reminder = doc.reminders[id]; + if (!reminder) return c.json({ error: "Reminder not found" }, 404); + return c.json(reminder); +}); + +// PUT /api/reminders/:id — update a reminder +routes.put("/api/reminders/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + const docId = mindersDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404); + + _syncServer!.changeDoc(docId, `update reminder ${id}`, (d) => { + const r = d.reminders[id]; + if (!r) return; + if (body.title !== undefined) r.title = body.title; + if (body.description !== undefined) r.description = body.description; + if (body.remindAt !== undefined) r.remindAt = typeof body.remindAt === "number" ? body.remindAt : new Date(body.remindAt).getTime(); + if (body.allDay !== undefined) r.allDay = body.allDay; + if (body.timezone !== undefined) r.timezone = body.timezone; + if (body.notifyEmail !== undefined) r.notifyEmail = body.notifyEmail; + if (body.cronExpression !== undefined) r.cronExpression = body.cronExpression; + r.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.reminders[id]); +}); + +// DELETE /api/reminders/:id — delete (cascades to calendar) +routes.delete("/api/reminders/:id", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = mindersDocId(dataSpace); + const doc = ensureDoc(dataSpace); + const reminder = doc.reminders[id]; + if (!reminder) return c.json({ error: "Reminder not found" }, 404); + + // Cascade: delete linked calendar event + if (reminder.calendarEventId) { + deleteCalendarEvent(dataSpace, reminder.calendarEventId); + } + + _syncServer!.changeDoc(docId, `delete reminder ${id}`, (d) => { + delete d.reminders[id]; + }); + + return c.json({ ok: true }); +}); + +// POST /api/reminders/:id/complete — mark completed +routes.post("/api/reminders/:id/complete", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = mindersDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404); + + _syncServer!.changeDoc(docId, `complete reminder ${id}`, (d) => { + const r = d.reminders[id]; + if (!r) return; + r.completed = true; + r.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.reminders[id]); +}); + +// POST /api/reminders/:id/snooze — reschedule to a new date +routes.post("/api/reminders/:id/snooze", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + const docId = mindersDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404); + + const newRemindAt = body.remindAt + ? (typeof body.remindAt === "number" ? body.remindAt : new Date(body.remindAt).getTime()) + : Date.now() + (body.hours || 24) * 3600000; + + _syncServer!.changeDoc(docId, `snooze reminder ${id}`, (d) => { + const r = d.reminders[id]; + if (!r) return; + r.remindAt = newRemindAt; + r.notified = false; + r.updatedAt = Date.now(); + }); + + // Update linked calendar event if exists + const updated = _syncServer!.getDoc(docId)!; + const reminder = updated.reminders[id]; + if (reminder?.calendarEventId) { + const calDocId = calendarDocId(dataSpace); + const duration = reminder.allDay ? 86400000 : 3600000; + _syncServer!.changeDoc(calDocId, `update reminder event time`, (d) => { + const ev = d.events[reminder.calendarEventId!]; + if (ev) { + ev.startTime = newRemindAt; + ev.endTime = newRemindAt + duration; + ev.updatedAt = Date.now(); + } + }); + } + + return c.json(updated.reminders[id]); +}); + +// ── Automation canvas page route ── + +routes.get("/reminders", (c) => { + const space = c.req.param("space") || "demo"; + return c.html( + renderShell({ + title: `${space} — Automations | rSpace`, + moduleId: "rminders", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + styles: ``, + }), + ); +}); + +// ── Workflow CRUD API ── + +routes.get("/api/workflows", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + // Ensure workflows field exists on older docs + const workflows = Object.values(doc.workflows || {}); + workflows.sort((a, b) => a.name.localeCompare(b.name)); + return c.json({ count: workflows.length, results: workflows }); +}); + +routes.post("/api/workflows", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + + const docId = mindersDocId(dataSpace); + ensureDoc(dataSpace); + const wfId = crypto.randomUUID(); + const now = Date.now(); + + const workflow: Workflow = { + id: wfId, + name: body.name || "New Workflow", + enabled: body.enabled !== false, + nodes: body.nodes || [], + edges: body.edges || [], + lastRunAt: null, + lastRunStatus: null, + runCount: 0, + createdAt: now, + updatedAt: now, + }; + + _syncServer!.changeDoc(docId, `create workflow ${wfId}`, (d) => { + if (!d.workflows) d.workflows = {} as any; + (d.workflows as any)[wfId] = workflow; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.workflows[wfId], 201); +}); + +routes.get("/api/workflows/:id", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const doc = ensureDoc(dataSpace); + + const wf = doc.workflows?.[id]; + if (!wf) return c.json({ error: "Workflow not found" }, 404); + return c.json(wf); +}); + +routes.put("/api/workflows/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + const docId = mindersDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404); + + _syncServer!.changeDoc(docId, `update workflow ${id}`, (d) => { + const wf = d.workflows[id]; + if (!wf) return; + if (body.name !== undefined) wf.name = body.name; + if (body.enabled !== undefined) wf.enabled = body.enabled; + if (body.nodes !== undefined) { + // Replace the nodes array + while (wf.nodes.length > 0) wf.nodes.splice(0, 1); + for (const n of body.nodes) wf.nodes.push(n); + } + if (body.edges !== undefined) { + while (wf.edges.length > 0) wf.edges.splice(0, 1); + for (const e of body.edges) wf.edges.push(e); + } + wf.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.workflows[id]); +}); + +routes.delete("/api/workflows/:id", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = mindersDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404); + + _syncServer!.changeDoc(docId, `delete workflow ${id}`, (d) => { + delete d.workflows[id]; + }); + + return c.json({ ok: true }); +}); + +// ── Workflow execution engine ── + +interface NodeResult { + nodeId: string; + status: "success" | "error" | "skipped"; + message: string; + durationMs: number; + outputData?: unknown; +} + +function topologicalSort(nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowNode[] { + const adj = new Map(); + const inDegree = new Map(); + + for (const n of nodes) { + adj.set(n.id, []); + inDegree.set(n.id, 0); + } + + for (const e of edges) { + adj.get(e.fromNode)?.push(e.toNode); + inDegree.set(e.toNode, (inDegree.get(e.toNode) || 0) + 1); + } + + const queue: string[] = []; + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id); + } + + const sorted: string[] = []; + while (queue.length > 0) { + const id = queue.shift()!; + sorted.push(id); + for (const neighbor of adj.get(id) || []) { + const newDeg = (inDegree.get(neighbor) || 1) - 1; + inDegree.set(neighbor, newDeg); + if (newDeg === 0) queue.push(neighbor); + } + } + + const nodeMap = new Map(nodes.map(n => [n.id, n])); + return sorted.map(id => nodeMap.get(id)!).filter(Boolean); +} + +function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLng = (lng2 - lng1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +async function executeWorkflowNode( + node: WorkflowNode, + inputData: unknown, + space: string, +): Promise<{ success: boolean; message: string; outputData?: unknown }> { + const cfg = node.config; + + switch (node.type) { + // ── Triggers ── + case "trigger-cron": + return { success: true, message: "Cron triggered", outputData: { timestamp: Date.now() } }; + + case "trigger-data-change": + return { success: true, message: `Watching ${cfg.module || "any"} module`, outputData: inputData || {} }; + + case "trigger-webhook": + return { success: true, message: "Webhook received", outputData: inputData || {} }; + + case "trigger-manual": + return { success: true, message: "Manual trigger fired", outputData: { timestamp: Date.now() } }; + + case "trigger-proximity": { + const data = inputData as { lat?: number; lng?: number } | undefined; + if (!data?.lat || !data?.lng) return { success: true, message: "No location data", outputData: { distance: null } }; + const dist = haversineKm(data.lat, data.lng, Number(cfg.lat) || 0, Number(cfg.lng) || 0); + const inRange = dist <= (Number(cfg.radiusKm) || 1); + return { success: true, message: `Distance: ${dist.toFixed(2)}km (${inRange ? "in range" : "out of range"})`, outputData: { distance: dist, inRange } }; + } + + // ── Conditions ── + case "condition-compare": { + const val = String(inputData ?? ""); + const cmp = String(cfg.compareValue ?? ""); + let result = false; + switch (cfg.operator) { + case "equals": result = val === cmp; break; + case "not-equals": result = val !== cmp; break; + case "greater-than": result = Number(val) > Number(cmp); break; + case "less-than": result = Number(val) < Number(cmp); break; + case "contains": result = val.includes(cmp); break; + } + return { success: true, message: `Compare: ${result}`, outputData: result }; + } + + case "condition-geofence": { + const coords = inputData as { lat?: number; lng?: number } | undefined; + if (!coords?.lat || !coords?.lng) return { success: true, message: "No coords", outputData: false }; + const dist = haversineKm(coords.lat, coords.lng, Number(cfg.centerLat) || 0, Number(cfg.centerLng) || 0); + const inside = dist <= (Number(cfg.radiusKm) || 5); + return { success: true, message: `Geofence: ${inside ? "inside" : "outside"} (${dist.toFixed(2)}km)`, outputData: inside }; + } + + case "condition-time-window": { + const now = new Date(); + const hour = now.getHours(); + const day = now.getDay(); + const startH = Number(cfg.startHour) || 0; + const endH = Number(cfg.endHour) || 23; + const days = String(cfg.days || "0,1,2,3,4,5,6").split(",").map(Number); + const inWindow = hour >= startH && hour < endH && days.includes(day); + return { success: true, message: `Time window: ${inWindow ? "in" : "outside"}`, outputData: inWindow }; + } + + case "condition-data-filter": { + const data = inputData as Record | undefined; + const field = String(cfg.field || ""); + const val = data?.[field]; + let match = false; + switch (cfg.operator) { + case "equals": match = String(val) === String(cfg.value); break; + case "not-equals": match = String(val) !== String(cfg.value); break; + case "contains": match = String(val ?? "").includes(String(cfg.value ?? "")); break; + case "exists": match = val !== undefined && val !== null; break; + } + return { success: true, message: `Filter: ${match ? "match" : "no match"}`, outputData: match ? data : null }; + } + + // ── Actions ── + case "action-send-email": { + const transport = getSmtpTransport(); + if (!transport) return { success: false, message: "SMTP not configured" }; + if (!cfg.to) return { success: false, message: "No recipient" }; + + const vars: Record = { + date: new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }), + timestamp: new Date().toISOString(), + ...(typeof inputData === "object" && inputData !== null + ? Object.fromEntries(Object.entries(inputData as Record).map(([k, v]) => [k, String(v)])) + : {}), + }; + + const subject = renderTemplate(String(cfg.subject || "Automation Notification"), vars); + const html = renderTemplate(String(cfg.bodyTemplate || `

Automation executed at ${vars.date}.

`), vars); + + await transport.sendMail({ + from: process.env.SMTP_FROM || "rMinders ", + to: String(cfg.to), + subject, + html, + }); + return { success: true, message: `Email sent to ${cfg.to}` }; + } + + case "action-post-webhook": { + if (!cfg.url) return { success: false, message: "No URL configured" }; + const method = String(cfg.method || "POST").toUpperCase(); + const vars: Record = { + timestamp: new Date().toISOString(), + ...(typeof inputData === "object" && inputData !== null + ? Object.fromEntries(Object.entries(inputData as Record).map(([k, v]) => [k, String(v)])) + : {}), + }; + const body = renderTemplate(String(cfg.bodyTemplate || JSON.stringify({ timestamp: vars.timestamp })), vars); + const res = await fetch(String(cfg.url), { + method, + headers: { "Content-Type": "application/json" }, + body: method !== "GET" ? body : undefined, + }); + if (!res.ok) return { success: false, message: `Webhook ${res.status}` }; + return { success: true, message: `Webhook ${method} ${cfg.url} -> ${res.status}`, outputData: await res.json().catch(() => null) }; + } + + case "action-create-event": { + if (!_syncServer) return { success: false, message: "SyncServer unavailable" }; + const calDocId = calendarDocId(space); + const calDoc = _syncServer.getDoc(calDocId); + if (!calDoc) return { success: false, message: "Calendar doc not found" }; + + const eventId = crypto.randomUUID(); + const now = Date.now(); + const durationMs = (Number(cfg.durationMinutes) || 60) * 60 * 1000; + + _syncServer.changeDoc(calDocId, `automation: create event`, (d) => { + d.events[eventId] = { + id: eventId, + title: String(cfg.title || "Automation Event"), + description: "Created by rMinders automation", + startTime: now, + endTime: now + durationMs, + allDay: false, + timezone: "UTC", + rrule: null, + status: null, + visibility: null, + sourceId: null, + sourceName: null, + sourceType: null, + sourceColor: null, + locationId: null, + locationName: null, + coordinates: null, + locationGranularity: null, + locationLat: null, + locationLng: null, + locationBreadcrumb: null, + bookingStatus: null, + isVirtual: false, + virtualUrl: null, + virtualPlatform: null, + rToolSource: "rMinders", + rToolEntityId: node.id, + attendees: [], + attendeeCount: 0, + tags: null, + metadata: null, + likelihood: null, + createdAt: now, + updatedAt: now, + }; + }); + return { success: true, message: `Event created: ${cfg.title || "Automation Event"}`, outputData: { eventId } }; + } + + case "action-create-task": { + if (!_syncServer) return { success: false, message: "SyncServer unavailable" }; + const title = String(cfg.title || "New task"); + const taskId = crypto.randomUUID(); + + // Find the default board for this space (first board doc) + const defaultBoardId = "default"; + const taskDocId = boardDocId(space, defaultBoardId); + let taskDoc = _syncServer.getDoc(taskDocId); + if (!taskDoc) { + // Initialize the board doc if it doesn't exist + const initDoc = Automerge.change(Automerge.init(), "init board", (d) => { + d.meta = { module: "tasks", collection: "boards", version: 1, spaceSlug: space, createdAt: Date.now() } as any; + d.board = { id: defaultBoardId, name: "Default Board", slug: "default", description: "", icon: null, ownerDid: null, statuses: ["TODO", "IN_PROGRESS", "DONE"], labels: [], createdAt: Date.now(), updatedAt: Date.now() } as any; + d.tasks = {} as any; + }); + _syncServer.setDoc(taskDocId, initDoc); + taskDoc = _syncServer.getDoc(taskDocId); + } + + const task = createTaskItem(taskId, space, title, { + description: String(cfg.description || ""), + priority: String(cfg.priority || "medium"), + status: "TODO", + }); + + _syncServer.changeDoc(taskDocId, `automation: create task`, (d) => { + d.tasks[taskId] = task; + }); + return { success: true, message: `Task created: ${title}`, outputData: { taskId, title } }; + } + + case "action-send-notification": { + const title = String(cfg.title || "Notification"); + const message = String(cfg.message || ""); + const level = String(cfg.level || "info"); + + // Log the notification server-side; delivery to clients happens via + // the community doc's eventLog (synced to all connected peers). + console.log(`[Automation] Notification [${level}]: ${title} — ${message}`); + return { success: true, message: `Notification sent: ${title}`, outputData: { title, message, level } }; + } + + case "action-update-data": { + if (!_syncServer) return { success: false, message: "SyncServer unavailable" }; + const module = String(cfg.module || ""); + const operation = String(cfg.operation || "update"); + let templateData: Record = {}; + try { + const vars: Record = { + timestamp: new Date().toISOString(), + ...(typeof inputData === "object" && inputData !== null + ? Object.fromEntries(Object.entries(inputData as Record).map(([k, v]) => [k, String(v)])) + : {}), + }; + const rendered = renderTemplate(String(cfg.template || "{}"), vars); + templateData = JSON.parse(rendered); + } catch { + return { success: false, message: "Invalid data template JSON" }; + } + + // Apply update to the target module's doc + const targetDocId = `${space}:${module}:default`; + const targetDoc = _syncServer.getDoc(targetDocId); + if (!targetDoc) return { success: false, message: `Doc not found: ${targetDocId}` }; + + _syncServer.changeDoc(targetDocId, `automation: ${operation}`, (d: any) => { + for (const [key, value] of Object.entries(templateData)) { + d[key] = value; + } + }); + return { success: true, message: `Data ${operation} applied to ${module}`, outputData: templateData }; + } + + default: + return { success: false, message: `Unknown node type: ${node.type}` }; + } +} + +async function executeWorkflow( + workflow: Workflow, + space: string, + triggerData?: unknown, +): Promise { + const sorted = topologicalSort(workflow.nodes, workflow.edges); + const results: NodeResult[] = []; + const nodeOutputs = new Map(); + + for (const node of sorted) { + const startMs = Date.now(); + + // Gather input data from upstream edges + let inputData: unknown = triggerData; + const incomingEdges = workflow.edges.filter(e => e.toNode === node.id); + if (incomingEdges.length > 0) { + // For conditions that output booleans: if the upstream condition result is false + // and this node connects via the "true" port, skip it + const upstreamNode = workflow.nodes.find(n => n.id === incomingEdges[0].fromNode); + const upstreamOutput = nodeOutputs.get(incomingEdges[0].fromNode); + + if (upstreamNode?.type.startsWith("condition-")) { + const port = incomingEdges[0].fromPort; + // Boolean result from condition + if (port === "true" && upstreamOutput === false) { + results.push({ nodeId: node.id, status: "skipped", message: "Condition false, skipping true branch", durationMs: 0 }); + continue; + } + if (port === "false" && upstreamOutput === true) { + results.push({ nodeId: node.id, status: "skipped", message: "Condition true, skipping false branch", durationMs: 0 }); + continue; + } + if (port === "inside" && upstreamOutput === false) { + results.push({ nodeId: node.id, status: "skipped", message: "Outside geofence, skipping inside branch", durationMs: 0 }); + continue; + } + if (port === "outside" && upstreamOutput === true) { + results.push({ nodeId: node.id, status: "skipped", message: "Inside geofence, skipping outside branch", durationMs: 0 }); + continue; + } + if (port === "in-window" && upstreamOutput === false) { + results.push({ nodeId: node.id, status: "skipped", message: "Outside time window", durationMs: 0 }); + continue; + } + if (port === "match" && upstreamOutput === null) { + results.push({ nodeId: node.id, status: "skipped", message: "Data filter: no match", durationMs: 0 }); + continue; + } + if (port === "no-match" && upstreamOutput !== null) { + results.push({ nodeId: node.id, status: "skipped", message: "Data filter: matched", durationMs: 0 }); + continue; + } + } + + // Use upstream output as input + if (upstreamOutput !== undefined) inputData = upstreamOutput; + } + + // Execute with retry (max 2 retries, exponential backoff 1s/2s) + const MAX_RETRIES = 2; + let lastError: string = ""; + let succeeded = false; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const result = await executeWorkflowNode(node, inputData, space); + const durationMs = Date.now() - startMs; + nodeOutputs.set(node.id, result.outputData); + results.push({ + nodeId: node.id, + status: result.success ? "success" : "error", + message: result.message + (attempt > 0 ? ` (retry ${attempt})` : ""), + durationMs, + outputData: result.outputData, + }); + succeeded = true; + break; + } catch (e: any) { + lastError = e.message || String(e); + if (attempt < MAX_RETRIES) { + await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt))); + } + } + } + + if (!succeeded) { + results.push({ + nodeId: node.id, + status: "error", + message: `${lastError} (after ${MAX_RETRIES + 1} attempts)`, + durationMs: Date.now() - startMs, + }); + } + } + + return results; +} + +/** Append a workflow execution log entry to the schedule doc. */ +function appendWorkflowLog( + space: string, + workflowId: string, + results: NodeResult[], + triggerType: string, +): void { + if (!_syncServer) return; + const docId = mindersDocId(space); + + const entry: WorkflowLogEntry = { + id: crypto.randomUUID(), + workflowId, + nodeResults: results.map(r => ({ + nodeId: r.nodeId, + status: r.status, + message: r.message, + durationMs: r.durationMs, + })), + overallStatus: results.every(r => r.status !== "error") ? "success" : "error", + timestamp: Date.now(), + triggerType, + }; + + _syncServer.changeDoc(docId, `log workflow ${workflowId}`, (d) => { + if (!d.workflowLog) d.workflowLog = [] as any; + (d.workflowLog as any).push(entry); + // Cap at MAX_WORKFLOW_LOG entries + while ((d.workflowLog as any).length > MAX_WORKFLOW_LOG) { + (d.workflowLog as any).splice(0, 1); + } + }); +} + +// POST /api/workflows/:id/run — manual execute +routes.post("/api/workflows/:id/run", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = mindersDocId(dataSpace); + const doc = ensureDoc(dataSpace); + const wf = doc.workflows?.[id]; + if (!wf) return c.json({ error: "Workflow not found" }, 404); + + const results = await executeWorkflow(wf, dataSpace); + + const allOk = results.every(r => r.status !== "error"); + _syncServer!.changeDoc(docId, `run workflow ${id}`, (d) => { + const w = d.workflows[id]; + if (!w) return; + w.lastRunAt = Date.now(); + w.lastRunStatus = allOk ? "success" : "error"; + w.runCount = (w.runCount || 0) + 1; + w.updatedAt = Date.now(); + }); + + appendWorkflowLog(dataSpace, id, results, "manual"); + + return c.json({ success: allOk, results }); +}); + +// POST /api/workflows/webhook/:hookId — external webhook trigger +routes.post("/api/workflows/webhook/:hookId", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const hookId = c.req.param("hookId"); + + const doc = ensureDoc(dataSpace); + let payload: unknown = {}; + try { payload = await c.req.json(); } catch { /* empty payload */ } + + // Find workflows with a trigger-webhook node matching this hookId + const matches: Workflow[] = []; + for (const wf of Object.values(doc.workflows || {})) { + if (!wf.enabled) continue; + for (const node of wf.nodes) { + if (node.type === "trigger-webhook" && node.config.hookId === hookId) { + matches.push(wf); + break; + } + } + } + + if (matches.length === 0) return c.json({ error: "No matching workflow" }, 404); + + const allResults: { workflowId: string; results: NodeResult[] }[] = []; + for (const wf of matches) { + const results = await executeWorkflow(wf, dataSpace, payload); + allResults.push({ workflowId: wf.id, results }); + appendWorkflowLog(dataSpace, wf.id, results, "webhook"); + } + + return c.json({ triggered: matches.length, results: allResults }); +}); + +// GET /api/workflows/log — workflow execution log +routes.get("/api/workflows/log", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + const log = [...(doc.workflowLog || [])].reverse(); // newest first + return c.json({ count: log.length, results: log }); +}); + +// ── Demo workflow seeds ── + +function seedDemoWorkflows(space: string) { + const docId = mindersDocId(space); + const doc = ensureDoc(space); + + if (Object.keys(doc.workflows || {}).length > 0) return; + + const now = Date.now(); + + const demo1Id = "demo-arriving-home"; + const demo1: Workflow = { + id: demo1Id, + name: "Arriving Home Notification", + enabled: false, + nodes: [ + { id: "n1", type: "trigger-proximity", label: "Location Proximity", position: { x: 50, y: 100 }, config: { lat: "49.2827", lng: "-123.1207", radiusKm: "1" } }, + { id: "n2", type: "condition-geofence", label: "Geofence Check", position: { x: 350, y: 100 }, config: { centerLat: "49.2827", centerLng: "-123.1207", radiusKm: "2" } }, + { id: "n3", type: "action-send-email", label: "Notify Family", position: { x: 650, y: 80 }, config: { to: "family@example.com", subject: "Almost home!", bodyTemplate: "

I'll be home in about {{distance}}km.

" } }, + ], + edges: [ + { id: "e1", fromNode: "n1", fromPort: "trigger", toNode: "n2", toPort: "trigger" }, + { id: "e2", fromNode: "n1", fromPort: "distance", toNode: "n2", toPort: "coords" }, + { id: "e3", fromNode: "n2", fromPort: "inside", toNode: "n3", toPort: "trigger" }, + ], + lastRunAt: null, + lastRunStatus: null, + runCount: 0, + createdAt: now, + updatedAt: now, + }; + + const demo2Id = "demo-signoff-pipeline"; + const demo2: Workflow = { + id: demo2Id, + name: "Document Sign-off Pipeline", + enabled: false, + nodes: [ + { id: "n1", type: "trigger-data-change", label: "Watch Sign-offs", position: { x: 50, y: 100 }, config: { module: "rnotes", field: "status" } }, + { id: "n2", type: "condition-compare", label: "Status = Signed", position: { x: 350, y: 100 }, config: { operator: "equals", compareValue: "signed" } }, + { id: "n3", type: "action-create-event", label: "Schedule Review", position: { x: 650, y: 60 }, config: { title: "Document Review Meeting", durationMinutes: "30" } }, + { id: "n4", type: "action-send-notification", label: "Notify Comms", position: { x: 650, y: 180 }, config: { title: "Sign-off Complete", message: "Document has been signed off", level: "success" } }, + ], + edges: [ + { id: "e1", fromNode: "n1", fromPort: "trigger", toNode: "n2", toPort: "trigger" }, + { id: "e2", fromNode: "n1", fromPort: "data", toNode: "n2", toPort: "value" }, + { id: "e3", fromNode: "n2", fromPort: "true", toNode: "n3", toPort: "trigger" }, + { id: "e4", fromNode: "n2", fromPort: "true", toNode: "n4", toPort: "trigger" }, + ], + lastRunAt: null, + lastRunStatus: null, + runCount: 0, + createdAt: now, + updatedAt: now, + }; + + _syncServer!.changeDoc(docId, "seed demo workflows", (d) => { + if (!d.workflows) d.workflows = {} as any; + (d.workflows as any)[demo1Id] = demo1; + (d.workflows as any)[demo2Id] = demo2; + }); + + console.log(`[Minders] Seeded 2 demo workflows for space "${space}"`); +} + +// ── Module export ── + +export const mindersModule: RSpaceModule = { + id: "rminders", + name: "rMinders", + icon: "⏱", + description: "Persistent cron-based job scheduling with email, webhooks, and backlog briefings", + scoping: { defaultScope: "global", userConfigurable: false }, + docSchemas: [ + { + pattern: "{space}:minders:jobs", + description: "Scheduled jobs and execution log", + init: mindersSchema.init, + }, + ], + routes, + landingPage: renderLanding, + seedTemplate: seedDefaultJobs, + async onInit(ctx) { + _syncServer = ctx.syncServer; + seedDefaultJobs("demo"); + seedDemoWorkflows("demo"); + startTickLoop(); + }, + feeds: [ + { + id: "executions", + name: "Executions", + kind: "data", + description: "Job execution events with status, timing, and output", + }, + ], + acceptsFeeds: ["data", "governance"], + outputPaths: [ + { path: "jobs", name: "Jobs", icon: "⏱", description: "Scheduled jobs and their configurations" }, + { path: "reminders", name: "Reminders", icon: "🔔", description: "Scheduled reminders with email notifications" }, + { path: "workflows", name: "Automations", icon: "🔀", description: "Visual automation workflows with triggers, conditions, and actions" }, + { path: "log", name: "Execution Log", icon: "📋", description: "History of job executions" }, + ], + onboardingActions: [ + { label: "Create a Schedule", icon: "⏱", description: "Set up a recurring job or reminder", type: 'create', href: '/{space}/rminders' }, + ], +}; + +// ── MI Data Export ── + +export interface MIReminderItem { + id: string; + title: string; + remindAt: number; + sourceModule: string | null; + sourceLabel: string | null; +} + +export function getUpcomingRemindersForMI(space: string, days = 14, limit = 5): MIReminderItem[] { + if (!_syncServer) return []; + const doc = _syncServer.getDoc(mindersDocId(space)); + if (!doc?.reminders) return []; + + const now = Date.now(); + const cutoff = now + days * 86400000; + + return Object.values(doc.reminders) + .filter(r => !r.completed && r.remindAt >= now && r.remindAt <= cutoff) + .sort((a, b) => a.remindAt - b.remindAt) + .slice(0, limit) + .map(r => ({ + id: r.id, + title: r.title, + remindAt: r.remindAt, + sourceModule: r.sourceModule, + sourceLabel: r.sourceLabel, + })); +} diff --git a/modules/rminders/schemas.ts b/modules/rminders/schemas.ts new file mode 100644 index 00000000..ae50a645 --- /dev/null +++ b/modules/rminders/schemas.ts @@ -0,0 +1,415 @@ +/** + * rMinders Automerge document schemas. + * + * Granularity: one Automerge document per space (all jobs + execution log). + * DocId format: {space}:minders:jobs + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export type ActionType = 'email' | 'webhook' | 'calendar-event' | 'broadcast' | 'backlog-briefing' | 'calendar-reminder'; + +export interface ScheduleJob { + id: string; + name: string; + description: string; + enabled: boolean; + + // Timing + cronExpression: string; + timezone: string; + + // Action + actionType: ActionType; + actionConfig: Record; + + // Execution state + lastRunAt: number | null; + lastRunStatus: 'success' | 'error' | null; + lastRunMessage: string; + nextRunAt: number | null; + runCount: number; + + // Metadata + createdBy: string; + createdAt: number; + updatedAt: number; +} + +export interface ExecutionLogEntry { + id: string; + jobId: string; + status: 'success' | 'error'; + message: string; + durationMs: number; + timestamp: number; +} + +export interface Reminder { + id: string; + title: string; + description: string; + remindAt: number; // epoch ms — when to fire + allDay: boolean; + timezone: string; + notifyEmail: string | null; + notified: boolean; // has email been sent? + completed: boolean; // dismissed by user? + + // Cross-module reference (null for free-form reminders) + sourceModule: string | null; // "rtasks", "rnotes", etc. + sourceEntityId: string | null; + sourceLabel: string | null; // "rTasks Task" + sourceColor: string | null; // "#f97316" + + // Optional recurrence + cronExpression: string | null; + + // Link to rCal event (bidirectional) + calendarEventId: string | null; + + createdBy: string; + createdAt: number; + updatedAt: number; +} + +// ── Workflow / Automation types ── + +export type AutomationNodeType = + // Triggers + | 'trigger-cron' + | 'trigger-data-change' + | 'trigger-webhook' + | 'trigger-manual' + | 'trigger-proximity' + // Conditions + | 'condition-compare' + | 'condition-geofence' + | 'condition-time-window' + | 'condition-data-filter' + // Actions + | 'action-send-email' + | 'action-post-webhook' + | 'action-create-event' + | 'action-create-task' + | 'action-send-notification' + | 'action-update-data'; + +export type AutomationNodeCategory = 'trigger' | 'condition' | 'action'; + +export interface WorkflowNodePort { + name: string; + type: 'trigger' | 'data' | 'boolean'; +} + +export interface WorkflowNode { + id: string; + type: AutomationNodeType; + label: string; + position: { x: number; y: number }; + config: Record; + // Runtime state (not persisted to Automerge — set during execution) + runtimeStatus?: 'idle' | 'running' | 'success' | 'error'; + runtimeMessage?: string; + runtimeDurationMs?: number; +} + +export interface WorkflowEdge { + id: string; + fromNode: string; + fromPort: string; + toNode: string; + toPort: string; +} + +export interface Workflow { + id: string; + name: string; + enabled: boolean; + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + lastRunAt: number | null; + lastRunStatus: 'success' | 'error' | null; + runCount: number; + createdAt: number; + updatedAt: number; +} + +export interface AutomationNodeDef { + type: AutomationNodeType; + category: AutomationNodeCategory; + label: string; + icon: string; + description: string; + inputs: WorkflowNodePort[]; + outputs: WorkflowNodePort[]; + configSchema: { key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea' | 'cron'; options?: string[]; placeholder?: string }[]; +} + +export const NODE_CATALOG: AutomationNodeDef[] = [ + // ── Triggers ── + { + type: 'trigger-cron', + category: 'trigger', + label: 'Cron Schedule', + icon: '⏰', + description: 'Fire on a cron schedule', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'timestamp', type: 'data' }], + configSchema: [ + { key: 'cronExpression', label: 'Cron Expression', type: 'cron', placeholder: '0 9 * * *' }, + { key: 'timezone', label: 'Timezone', type: 'text', placeholder: 'America/Vancouver' }, + ], + }, + { + type: 'trigger-data-change', + category: 'trigger', + label: 'Data Change', + icon: '📊', + description: 'Fire when data changes in any rApp', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + configSchema: [ + { key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rtasks', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] }, + { key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' }, + ], + }, + { + type: 'trigger-webhook', + category: 'trigger', + label: 'Webhook Incoming', + icon: '🔗', + description: 'Fire when an external webhook is received', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'payload', type: 'data' }], + configSchema: [ + { key: 'hookId', label: 'Hook ID', type: 'text', placeholder: 'auto-generated' }, + ], + }, + { + type: 'trigger-manual', + category: 'trigger', + label: 'Manual Trigger', + icon: '👆', + description: 'Fire manually via button click', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }], + configSchema: [], + }, + { + type: 'trigger-proximity', + category: 'trigger', + label: 'Location Proximity', + icon: '📍', + description: 'Fire when a location approaches a point', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'distance', type: 'data' }], + configSchema: [ + { key: 'lat', label: 'Latitude', type: 'number', placeholder: '49.2827' }, + { key: 'lng', label: 'Longitude', type: 'number', placeholder: '-123.1207' }, + { key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '1' }, + ], + }, + // ── Conditions ── + { + type: 'condition-compare', + category: 'condition', + label: 'Compare Values', + icon: '⚖️', + description: 'Compare two values', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'value', type: 'data' }], + outputs: [{ name: 'true', type: 'trigger' }, { name: 'false', type: 'trigger' }], + configSchema: [ + { key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'greater-than', 'less-than', 'contains'] }, + { key: 'compareValue', label: 'Compare To', type: 'text', placeholder: 'value' }, + ], + }, + { + type: 'condition-geofence', + category: 'condition', + label: 'Geofence Check', + icon: '🗺️', + description: 'Check if coordinates are within a radius', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'coords', type: 'data' }], + outputs: [{ name: 'inside', type: 'trigger' }, { name: 'outside', type: 'trigger' }], + configSchema: [ + { key: 'centerLat', label: 'Center Lat', type: 'number', placeholder: '49.2827' }, + { key: 'centerLng', label: 'Center Lng', type: 'number', placeholder: '-123.1207' }, + { key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '5' }, + ], + }, + { + type: 'condition-time-window', + category: 'condition', + label: 'Time Window', + icon: '🕐', + description: 'Check if current time is within a window', + inputs: [{ name: 'trigger', type: 'trigger' }], + outputs: [{ name: 'in-window', type: 'trigger' }, { name: 'outside', type: 'trigger' }], + configSchema: [ + { key: 'startHour', label: 'Start Hour (0-23)', type: 'number', placeholder: '9' }, + { key: 'endHour', label: 'End Hour (0-23)', type: 'number', placeholder: '17' }, + { key: 'days', label: 'Days', type: 'text', placeholder: '1,2,3,4,5' }, + ], + }, + { + type: 'condition-data-filter', + category: 'condition', + label: 'Data Filter', + icon: '🔍', + description: 'Filter data by field value', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'match', type: 'trigger' }, { name: 'no-match', type: 'trigger' }, { name: 'filtered', type: 'data' }], + configSchema: [ + { key: 'field', label: 'Field Path', type: 'text', placeholder: 'status' }, + { key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'contains', 'exists'] }, + { key: 'value', label: 'Value', type: 'text', placeholder: 'completed' }, + ], + }, + // ── Actions ── + { + type: 'action-send-email', + category: 'action', + label: 'Send Email', + icon: '📧', + description: 'Send an email via SMTP', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }], + configSchema: [ + { key: 'to', label: 'To', type: 'text', placeholder: 'user@example.com' }, + { key: 'subject', label: 'Subject', type: 'text', placeholder: 'Notification from rSpace' }, + { key: 'bodyTemplate', label: 'Body (HTML)', type: 'textarea', placeholder: '

Hello {{name}}

' }, + ], + }, + { + type: 'action-post-webhook', + category: 'action', + label: 'POST Webhook', + icon: '🌐', + description: 'Send an HTTP POST to an external URL', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'response', type: 'data' }], + configSchema: [ + { key: 'url', label: 'URL', type: 'text', placeholder: 'https://api.example.com/hook' }, + { key: 'method', label: 'Method', type: 'select', options: ['POST', 'PUT', 'PATCH'] }, + { key: 'bodyTemplate', label: 'Body Template', type: 'textarea', placeholder: '{"event": "{{event}}"}' }, + ], + }, + { + type: 'action-create-event', + category: 'action', + label: 'Create Calendar Event', + icon: '📅', + description: 'Create an event in rCal', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'eventId', type: 'data' }], + configSchema: [ + { key: 'title', label: 'Event Title', type: 'text', placeholder: 'Meeting' }, + { key: 'durationMinutes', label: 'Duration (min)', type: 'number', placeholder: '60' }, + ], + }, + { + type: 'action-create-task', + category: 'action', + label: 'Create Task', + icon: '✅', + description: 'Create a task in rTasks', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }], + configSchema: [ + { key: 'title', label: 'Task Title', type: 'text', placeholder: 'New task' }, + { key: 'description', label: 'Description', type: 'textarea', placeholder: 'Task details...' }, + { key: 'priority', label: 'Priority', type: 'select', options: ['low', 'medium', 'high', 'urgent'] }, + ], + }, + { + type: 'action-send-notification', + category: 'action', + label: 'Send Notification', + icon: '🔔', + description: 'Send an in-app notification', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }], + configSchema: [ + { key: 'title', label: 'Title', type: 'text', placeholder: 'Notification' }, + { key: 'message', label: 'Message', type: 'textarea', placeholder: 'Something happened...' }, + { key: 'level', label: 'Level', type: 'select', options: ['info', 'warning', 'error', 'success'] }, + ], + }, + { + type: 'action-update-data', + category: 'action', + label: 'Update Data', + icon: '💾', + description: 'Update data in an rApp document', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }], + configSchema: [ + { key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rtasks', 'rcal', 'rnetwork'] }, + { key: 'operation', label: 'Operation', type: 'select', options: ['create', 'update', 'delete'] }, + { key: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' }, + ], + }, +]; + +export interface WorkflowLogEntry { + id: string; + workflowId: string; + nodeResults: { nodeId: string; status: string; message: string; durationMs: number }[]; + overallStatus: 'success' | 'error'; + timestamp: number; + triggerType: string; +} + +export interface MindersDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + jobs: Record; + reminders: Record; + workflows: Record; + log: ExecutionLogEntry[]; + workflowLog?: WorkflowLogEntry[]; +} + +// ── Schema registration ── + +export const mindersSchema: DocSchema = { + module: 'minders', + collection: 'jobs', + version: 1, + init: (): MindersDoc => ({ + meta: { + module: 'minders', + collection: 'jobs', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + jobs: {}, + reminders: {}, + workflows: {}, + log: [], + }), +}; + +// ── Helpers ── + +export function mindersDocId(space: string) { + return `${space}:minders:jobs` as const; +} + +/** Maximum execution log entries to keep per doc */ +export const MAX_LOG_ENTRIES = 200; + +/** Maximum workflow log entries to keep per doc */ +export const MAX_WORKFLOW_LOG = 100; + +/** Maximum reminders per space */ +export const MAX_REMINDERS = 500;