Merge branch 'dev'
CI/CD / deploy (push) Failing after 54s
Details
CI/CD / deploy (push) Failing after 54s
Details
This commit is contained in:
commit
a5824a7ba7
|
|
@ -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
|
|
@ -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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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">✓</button>
|
||||
<button class="rw-btn-sm rw-btn-ghost" data-snooze="${r.id}" title="Snooze 24h">◴</button>
|
||||
<button class="rw-btn-sm rw-btn-ghost" data-delete="${r.id}" title="Delete">✕</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">🔔 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);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/* rMinders module — dark theme */
|
||||
folk-minders-app {
|
||||
display: block;
|
||||
min-height: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
|
@ -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 — 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">⏰</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">📧</span>
|
||||
</div>
|
||||
<h3>Email Actions</h3>
|
||||
<p>Send scheduled emails via SMTP — 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">🔗</span>
|
||||
</div>
|
||||
<h3>Webhook Actions</h3>
|
||||
<p>Fire HTTP requests on schedule — 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">📋</span>
|
||||
</div>
|
||||
<h3>Backlog Briefings</h3>
|
||||
<p>Automated task digests from your Backlog — 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…</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' ? '✓' : w.lastRunStatus === 'error' ? '✗' : '—';
|
||||
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' ? '✓' : w.lastRunStatus === 'error' ? '✗' : '—';
|
||||
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 →</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 →</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 — 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">← Back to rSpace</a>
|
||||
</p>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
Loading…
Reference in New Issue