Compare commits

...

2 Commits

Author SHA1 Message Date
Jeff Emmett a5824a7ba7 Merge branch 'dev'
CI/CD / deploy (push) Failing after 54s Details
2026-04-16 17:16:22 -04:00
Jeff Emmett 312ea4b535 fix(build): commit rminders module dir (orphaned during rschedule rename)
Files existed on disk and were referenced by vite.config.ts entry
config, but were never git-added when the rschedule→rminders rename
happened in dda7760. Build on a fresh clone failed with "Could not
resolve entry module modules/rschedule/components/folk-schedule-app.ts"
because vite was picking up the stale pre-rename config on disk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 17:16:15 -04:00
9 changed files with 5879 additions and 0 deletions

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,293 @@
/**
* <folk-reminders-widget> 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<typeof setInterval> | 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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
private render() {
const styles = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
.rw-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.rw-title { font-size: 14px; font-weight: 600; color: #f59e0b; }
.rw-btn { padding: 4px 10px; border-radius: 6px; border: none; cursor: pointer; font-size: 12px; font-weight: 600; transition: all 0.15s; }
.rw-btn-primary { background: linear-gradient(135deg, #f59e0b, #f97316); color: #0f172a; }
.rw-btn-primary:hover { opacity: 0.9; }
.rw-btn-sm { padding: 2px 6px; font-size: 10px; border-radius: 4px; border: none; cursor: pointer; }
.rw-btn-ghost { background: transparent; color: var(--rs-text-muted); }
.rw-btn-ghost:hover { color: var(--rs-text-primary); }
.rw-card { display: flex; align-items: flex-start; gap: 8px; padding: 8px; border-radius: 6px; margin-bottom: 4px; transition: background 0.15s; }
.rw-card:hover { background: rgba(255,255,255,0.05); }
.rw-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
.rw-info { flex: 1; min-width: 0; }
.rw-card-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rw-card-meta { font-size: 11px; color: var(--rs-text-muted); margin-top: 2px; }
.rw-card-source { font-size: 10px; color: var(--rs-text-secondary); }
.rw-actions { display: flex; gap: 2px; flex-shrink: 0; }
.rw-empty { text-align: center; padding: 20px; color: var(--rs-text-muted); font-size: 13px; }
.rw-loading { text-align: center; padding: 20px; color: var(--rs-text-secondary); font-size: 13px; }
.rw-form { background: var(--rs-bg-surface-sunken); border: 1px solid rgba(30,41,59,0.8); border-radius: 8px; padding: 10px; margin-bottom: 8px; }
.rw-input { width: 100%; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--rs-border-strong); background: rgba(30,41,59,0.8); color: var(--rs-text-primary); font-size: 13px; margin-bottom: 6px; box-sizing: border-box; outline: none; font-family: inherit; }
.rw-input:focus { border-color: #f59e0b; }
.rw-form-actions { display: flex; gap: 6px; }
.widget-drop-active { background: rgba(245,158,11,0.1); border: 2px dashed #f59e0b; border-radius: 8px; }
</style>
`;
if (this.loading) {
this.shadow.innerHTML = `${styles}<div class="rw-loading">Loading reminders...</div>`;
return;
}
const cards = this.reminders.map(r => `
<div class="rw-card">
<div class="rw-dot" style="background:${r.sourceColor || "#f59e0b"}"></div>
<div class="rw-info">
<div class="rw-card-title">${this.esc(r.title)}</div>
<div class="rw-card-meta">${this.formatRelativeTime(r.remindAt)}</div>
${r.sourceLabel ? `<div class="rw-card-source">${this.esc(r.sourceLabel)}</div>` : ""}
</div>
<div class="rw-actions">
<button class="rw-btn-sm rw-btn-ghost" data-complete="${r.id}" title="Complete">&#10003;</button>
<button class="rw-btn-sm rw-btn-ghost" data-snooze="${r.id}" title="Snooze 24h">&#9716;</button>
<button class="rw-btn-sm rw-btn-ghost" data-delete="${r.id}" title="Delete">&#10005;</button>
</div>
</div>
`).join("");
const addForm = this.showAddForm ? `
<div class="rw-form">
<input type="text" class="rw-input" id="rw-title" placeholder="Reminder title..." value="${this.esc(this.formTitle)}">
<input type="date" class="rw-input" id="rw-date" value="${this.formDate}">
<div class="rw-form-actions">
<button class="rw-btn rw-btn-primary" data-action="submit">Add</button>
<button class="rw-btn rw-btn-ghost" data-action="cancel">Cancel</button>
</div>
</div>
` : "";
this.shadow.innerHTML = `
${styles}
<div id="widget-root">
<div class="rw-header">
<span class="rw-title">&#128276; Reminders</span>
<button class="rw-btn rw-btn-primary" data-action="add">+ Add</button>
</div>
${addForm}
${this.reminders.length > 0 ? cards : '<div class="rw-empty">No upcoming reminders</div>'}
</div>
`;
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<HTMLButtonElement>("[data-complete]").forEach(btn => {
btn.addEventListener("click", () => this.completeReminder(btn.dataset.complete!));
});
// Snooze
this.shadow.querySelectorAll<HTMLButtonElement>("[data-snooze]").forEach(btn => {
btn.addEventListener("click", () => this.snoozeReminder(btn.dataset.snooze!));
});
// Delete
this.shadow.querySelectorAll<HTMLButtonElement>("[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);

View File

@ -0,0 +1,6 @@
/* rMinders module — dark theme */
folk-minders-app {
display: block;
min-height: 400px;
padding: 20px;
}

261
modules/rminders/landing.ts Normal file
View File

@ -0,0 +1,261 @@
/**
* rMinders landing page persistent reminders + job scheduling + automation canvas.
*/
export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline" style="color:#f59e0b;background:rgba(245,158,11,0.1);border-color:rgba(245,158,11,0.2)">
Persistent Scheduling
</span>
<h1 class="rl-heading" style="background:linear-gradient(to right,#f59e0b,#f97316,#ef4444);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Automate (you)rSpace,<br>with (you)rMinders.
</h1>
<p class="rl-subtitle">
Cron-powered job scheduling with email, webhooks, calendar events, and backlog briefings &mdash; all managed from within rSpace.
</p>
<p class="rl-subtext">
rMinders replaces system-level crontabs with an <span style="color:#f59e0b;font-weight:600">in-process, persistent scheduler</span>.
Jobs survive restarts, fire on a 60-second tick loop, and are fully configurable through the UI.
</p>
<div class="rl-cta-row">
<a href="#" class="rl-cta-primary" id="ml-primary"
style="background:linear-gradient(to right,#f59e0b,#f97316);color:#0b1120"
onclick="var s=document.querySelector('.rl-hero').closest('[data-space]')?.getAttribute('data-space');if(s&&window.__rspaceNavUrl)window.location.href=window.__rspaceNavUrl(s,'rminders');return false;">
Open Scheduler
</a>
<a href="#" class="rl-cta-primary" id="ml-automations"
style="background:linear-gradient(to right,#8b5cf6,#6366f1);color:#fff"
onclick="var s=document.querySelector('.rl-hero').closest('[data-space]')?.getAttribute('data-space');if(s&&window.__rspaceNavUrl)window.location.href=window.__rspaceNavUrl(s,'rminders')+'/reminders';return false;">
Automation Canvas
</a>
<a href="#features" class="rl-cta-secondary">Learn More</a>
</div>
</div>
<!-- Features (4-card grid) -->
<section class="rl-section" id="features" style="border-top:none">
<div class="rl-container">
<div class="rl-grid-4">
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(245,158,11,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#9200;</span>
</div>
<h3>Cron Expressions</h3>
<p>Standard cron syntax with timezone support. Schedule anything from every minute to once a year.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(249,115,22,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128231;</span>
</div>
<h3>Email Actions</h3>
<p>Send scheduled emails via SMTP &mdash; morning briefings, weekly digests, monthly audits.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(239,68,68,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128279;</span>
</div>
<h3>Webhook Actions</h3>
<p>Fire HTTP requests on schedule &mdash; trigger builds, sync data, or ping external services.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(52,211,153,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128203;</span>
</div>
<h3>Backlog Briefings</h3>
<p>Automated task digests from your Backlog &mdash; morning, weekly, and monthly summaries delivered by email.</p>
</div>
</div>
</div>
</section>
<!-- Your Automations -->
<section class="rl-section" id="automations">
<div class="rl-container">
<h2 style="text-align:center;font-size:1.5rem;margin-bottom:0.5rem;color:#e2e8f0">Your Automations</h2>
<p style="text-align:center;color:rgba(148,163,184,0.7);margin-bottom:2rem;font-size:0.9rem">
Visual workflows built on the automation canvas
</p>
<div id="rs-automations-list" style="min-height:120px">
<p style="text-align:center;color:rgba(148,163,184,0.5);padding:2rem 0">Loading automations&hellip;</p>
</div>
</div>
</section>
<script>
(function() {
var space = 'demo';
var el = document.querySelector('.rl-hero');
if (el) {
var ds = el.closest('[data-space]');
if (ds) space = ds.getAttribute('data-space') || 'demo';
}
var basePath = '/' + space + '/rminders/';
var container = document.getElementById('rs-automations-list');
fetch(basePath + 'api/workflows')
.then(function(r) { return r.ok ? r.json() : { results: [] }; })
.then(function(data) {
var wfs = data.results || [];
if (wfs.length === 0) {
container.innerHTML =
'<div style="text-align:center;padding:2.5rem 1rem">' +
'<p style="color:rgba(148,163,184,0.6);margin-bottom:1.5rem">No automations yet.</p>' +
'<a href="' + basePath + 'reminders" ' +
'style="display:inline-block;padding:0.6rem 1.5rem;border-radius:8px;' +
'background:linear-gradient(to right,#8b5cf6,#6366f1);color:#fff;text-decoration:none;font-weight:600;font-size:0.9rem">' +
'+ Create your first automation</a>' +
'</div>';
return;
}
var viewMode = wfs.length > 6 ? 'list' : 'grid';
var html = '';
if (viewMode === 'grid') {
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1rem">';
for (var i = 0; i < wfs.length; i++) {
var w = wfs[i];
var nodeCount = (w.nodes || []).length;
var edgeCount = (w.edges || []).length;
var statusColor = w.enabled ? '#34d399' : '#64748b';
var statusLabel = w.enabled ? 'Active' : 'Disabled';
var lastRun = w.lastRunAt ? new Date(w.lastRunAt).toLocaleDateString() : 'Never';
var runStatusIcon = w.lastRunStatus === 'success' ? '&#10003;' : w.lastRunStatus === 'error' ? '&#10007;' : '&mdash;';
var runStatusColor = w.lastRunStatus === 'success' ? '#34d399' : w.lastRunStatus === 'error' ? '#ef4444' : '#64748b';
// Build a mini node-count summary
var triggers = 0, conditions = 0, actions = 0;
for (var n = 0; n < (w.nodes || []).length; n++) {
var t = (w.nodes[n].type || '');
if (t.indexOf('trigger') === 0) triggers++;
else if (t.indexOf('condition') === 0) conditions++;
else if (t.indexOf('action') === 0) actions++;
}
html += '<a href="' + basePath + 'reminders?wf=' + w.id + '" ' +
'style="display:block;text-decoration:none;border-radius:12px;' +
'background:rgba(30,41,59,0.6);border:1px solid rgba(148,163,184,0.12);' +
'padding:1.25rem;transition:border-color 0.2s,transform 0.15s;cursor:pointer" ' +
'onmouseover="this.style.borderColor=\'rgba(139,92,246,0.4)\';this.style.transform=\'translateY(-2px)\'" ' +
'onmouseout="this.style.borderColor=\'rgba(148,163,184,0.12)\';this.style.transform=\'none\'">' +
// Header row
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem">' +
'<span style="font-weight:600;color:#e2e8f0;font-size:0.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (w.name || 'Untitled') + '</span>' +
'<span style="font-size:0.7rem;padding:2px 8px;border-radius:9999px;background:' + statusColor + '20;color:' + statusColor + ';font-weight:500;white-space:nowrap">' + statusLabel + '</span>' +
'</div>' +
// Node summary
'<div style="display:flex;gap:0.75rem;margin-bottom:0.6rem;font-size:0.8rem;color:rgba(148,163,184,0.7)">' +
(triggers ? '<span style="color:#60a5fa">' + triggers + ' trigger' + (triggers > 1 ? 's' : '') + '</span>' : '') +
(conditions ? '<span style="color:#fbbf24">' + conditions + ' condition' + (conditions > 1 ? 's' : '') + '</span>' : '') +
(actions ? '<span style="color:#34d399">' + actions + ' action' + (actions > 1 ? 's' : '') + '</span>' : '') +
(!nodeCount ? '<span>Empty workflow</span>' : '') +
'</div>' +
// Footer
'<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.75rem;color:rgba(148,163,184,0.5)">' +
'<span>Runs: ' + (w.runCount || 0) + '</span>' +
'<span>Last: <span style="color:' + runStatusColor + '">' + runStatusIcon + '</span> ' + lastRun + '</span>' +
'</div>' +
'</a>';
}
html += '</div>';
} else {
// List view for many workflows
html += '<div style="display:flex;flex-direction:column;gap:0.5rem">';
for (var i = 0; i < wfs.length; i++) {
var w = wfs[i];
var nodeCount = (w.nodes || []).length;
var statusColor = w.enabled ? '#34d399' : '#64748b';
var statusLabel = w.enabled ? 'Active' : 'Disabled';
var lastRun = w.lastRunAt ? new Date(w.lastRunAt).toLocaleDateString() : 'Never';
var runStatusIcon = w.lastRunStatus === 'success' ? '&#10003;' : w.lastRunStatus === 'error' ? '&#10007;' : '&mdash;';
var runStatusColor = w.lastRunStatus === 'success' ? '#34d399' : w.lastRunStatus === 'error' ? '#ef4444' : '#64748b';
html += '<a href="' + basePath + 'reminders?wf=' + w.id + '" ' +
'style="display:flex;align-items:center;gap:1rem;text-decoration:none;border-radius:8px;' +
'background:rgba(30,41,59,0.4);border:1px solid rgba(148,163,184,0.08);' +
'padding:0.75rem 1rem;transition:border-color 0.2s" ' +
'onmouseover="this.style.borderColor=\'rgba(139,92,246,0.4)\'" ' +
'onmouseout="this.style.borderColor=\'rgba(148,163,184,0.08)\'">' +
'<span style="width:8px;height:8px;border-radius:50%;background:' + statusColor + ';flex-shrink:0"></span>' +
'<span style="flex:1;font-weight:500;color:#e2e8f0;font-size:0.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (w.name || 'Untitled') + '</span>' +
'<span style="font-size:0.75rem;color:rgba(148,163,184,0.5)">' + nodeCount + ' nodes</span>' +
'<span style="font-size:0.75rem;color:rgba(148,163,184,0.5)">' + (w.runCount || 0) + ' runs</span>' +
'<span style="font-size:0.75rem;color:' + runStatusColor + '">' + runStatusIcon + ' ' + lastRun + '</span>' +
'</a>';
}
html += '</div>';
}
// Add "Open Canvas" link at the bottom
html += '<div style="text-align:center;margin-top:1.5rem">' +
'<a href="' + basePath + 'reminders" ' +
'style="color:#8b5cf6;text-decoration:none;font-size:0.9rem;font-weight:500">' +
'Open Automation Canvas &rarr;</a>' +
'</div>';
container.innerHTML = html;
})
.catch(function() {
container.innerHTML =
'<div style="text-align:center;padding:2rem">' +
'<p style="color:rgba(148,163,184,0.5)">Could not load automations.</p>' +
'<a href="' + basePath + 'reminders" ' +
'style="color:#8b5cf6;text-decoration:none;font-size:0.9rem">Open Automation Canvas &rarr;</a>' +
'</div>';
});
})();
</script>
<!-- How it works -->
<section class="rl-section">
<div class="rl-container">
<h2 style="text-align:center;font-size:1.5rem;margin-bottom:2rem;color:#e2e8f0">How it works</h2>
<div class="rl-grid-2">
<div class="rl-card" style="padding:2rem">
<h3 style="color:#f59e0b">Persistent Jobs</h3>
<p>Jobs are stored in Automerge documents &mdash; they survive container restarts and server reboots. No more lost crontabs.</p>
</div>
<div class="rl-card" style="padding:2rem">
<h3 style="color:#f97316">60-Second Tick Loop</h3>
<p>A lightweight in-process loop checks every 60 seconds for due jobs. No external scheduler process needed.</p>
</div>
</div>
</div>
</section>
<!-- Ecosystem integration -->
<section class="rl-section">
<div class="rl-container">
<h2 style="text-align:center;font-size:1.5rem;margin-bottom:2rem;color:#e2e8f0">Ecosystem Integration</h2>
<div class="rl-grid-3">
<div class="rl-card rl-card--center" style="padding:1.5rem">
<h3>rCal</h3>
<p>Create recurring calendar events automatically via the calendar-event action type.</p>
</div>
<div class="rl-card rl-card--center" style="padding:1.5rem">
<h3>rInbox</h3>
<p>Schedule email delivery through shared SMTP infrastructure.</p>
</div>
<div class="rl-card rl-card--center" style="padding:1.5rem">
<h3>Backlog</h3>
<p>Scan backlog tasks and generate automated priority briefings on any cadence.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="rl-section" style="text-align:center;padding:4rem 0">
<h2 class="rl-heading" style="font-size:1.75rem;background:linear-gradient(to right,#f59e0b,#f97316);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Stop managing crontabs. Start minding from rSpace.
</h2>
<p style="color:rgba(148,163,184,0.8);margin-top:1rem">
<a href="/" style="color:#f59e0b;text-decoration:none">&larr; Back to rSpace</a>
</p>
</section>
`;
}

View File

@ -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<void> {
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<MindersDoc>(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<MindersDoc | null> {
const docId = mindersDocId(this.#space) as DocumentId;
let doc = this.#documents.get<MindersDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<MindersDoc>(docId, mindersSchema, binary)
: this.#documents.open<MindersDoc>(docId, mindersSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getDoc(): MindersDoc | undefined {
return this.#documents.get<MindersDoc>(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<MindersDoc>(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<MindersDoc>(docId, `Delete job`, (d) => {
delete d.jobs[jobId];
});
}
toggleJob(jobId: string, enabled: boolean): void {
const docId = mindersDocId(this.#space) as DocumentId;
this.#sync.change<MindersDoc>(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<MindersDoc>(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<MindersDoc>(docId, `Delete reminder`, (d) => {
delete d.reminders[reminderId];
});
}
completeReminder(reminderId: string): void {
const docId = mindersDocId(this.#space) as DocumentId;
this.#sync.change<MindersDoc>(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<MindersDoc>(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<MindersDoc>(docId, `Delete workflow`, (d) => {
delete d.workflows[workflowId];
});
}
// ── Execution Log ──
appendLogEntry(entry: ExecutionLogEntry): void {
const docId = mindersDocId(this.#space) as DocumentId;
this.#sync.change<MindersDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

2141
modules/rminders/mod.ts Normal file

File diff suppressed because it is too large Load Diff

415
modules/rminders/schemas.ts Normal file
View File

@ -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<string, unknown>;
// 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<string, unknown>;
// 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: '<p>Hello {{name}}</p>' },
],
},
{
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<string, ScheduleJob>;
reminders: Record<string, Reminder>;
workflows: Record<string, Workflow>;
log: ExecutionLogEntry[];
workflowLog?: WorkflowLogEntry[];
}
// ── Schema registration ──
export const mindersSchema: DocSchema<MindersDoc> = {
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;