infra(traefik): scope rate limit per CF-Connecting-IP, raise to 600/150
Previous limits (avg 120/min, burst 30) had no sourceCriterion. Traefik default groups by request Host, so ALL users of rspace.online shared a single 120/min bucket — tripped almost immediately under normal load (repeated 429s in rpast, api/mi/models, etc). Scope per Cloudflare client IP (CF-Connecting-IP header) and raise to 600/min average with 150 burst — interactive use can spike above 120/min from one client easily (module loads + polling + autosave).
This commit is contained in:
parent
68648608a9
commit
dda7760433
|
|
@ -175,10 +175,14 @@ services:
|
||||||
- "traefik.http.routers.rspace-rsocials.entrypoints=web"
|
- "traefik.http.routers.rspace-rsocials.entrypoints=web"
|
||||||
- "traefik.http.routers.rspace-rsocials.priority=120"
|
- "traefik.http.routers.rspace-rsocials.priority=120"
|
||||||
- "traefik.http.routers.rspace-rsocials.service=rspace-online"
|
- "traefik.http.routers.rspace-rsocials.service=rspace-online"
|
||||||
# Rate limiting middleware (coarse edge defense — token bucket per source)
|
# Rate limiting middleware (coarse edge defense — token bucket per client IP)
|
||||||
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.average=120"
|
# Without sourceCriterion Traefik groups by request Host, so one bucket is
|
||||||
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.burst=30"
|
# shared across ALL users of a domain — trips instantly under normal use.
|
||||||
|
# Scope per client IP using Cloudflare's CF-Connecting-IP header.
|
||||||
|
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.average=600"
|
||||||
|
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.burst=150"
|
||||||
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.period=1m"
|
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.period=1m"
|
||||||
|
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.sourcecriterion.requestheadername=CF-Connecting-IP"
|
||||||
- "traefik.http.routers.rspace-main.middlewares=rspace-ratelimit"
|
- "traefik.http.routers.rspace-main.middlewares=rspace-ratelimit"
|
||||||
- "traefik.http.routers.rspace-canvas.middlewares=rspace-ratelimit"
|
- "traefik.http.routers.rspace-canvas.middlewares=rspace-ratelimit"
|
||||||
# Service configuration
|
# Service configuration
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,23 @@ const styles = css`
|
||||||
width: 16px;
|
width: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Touch devices: enlarge handles to ≥22px so they're reachable,
|
||||||
|
and make rotation handles visible (tinted dot). Hover-only reveal
|
||||||
|
doesn't work on touch. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
[part^="resize"] {
|
||||||
|
width: 22px;
|
||||||
|
border-width: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
[part^="rotation"] {
|
||||||
|
opacity: 0.6;
|
||||||
|
width: 26px;
|
||||||
|
background: hsl(214, 84%, 56%);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[part$="top-left"] {
|
[part$="top-left"] {
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* <folk-payments-dashboard> — Payments dashboard showing requests in/out.
|
* <folk-payments-dashboard> — Payments dashboard showing payment requests.
|
||||||
*
|
|
||||||
* Tabs:
|
|
||||||
* - Payments In: payment requests the user created (money coming to them)
|
|
||||||
* - Payments Out: placeholder for future on-chain tx tracking
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface PaymentRow {
|
interface PaymentRow {
|
||||||
|
|
@ -27,7 +23,6 @@ class FolkPaymentsDashboard extends HTMLElement {
|
||||||
private authenticated = false;
|
private authenticated = false;
|
||||||
private loading = true;
|
private loading = true;
|
||||||
private error = '';
|
private error = '';
|
||||||
private activeTab: 'in' | 'out' = 'in';
|
|
||||||
private payments: PaymentRow[] = [];
|
private payments: PaymentRow[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -119,16 +114,8 @@ class FolkPaymentsDashboard extends HTMLElement {
|
||||||
<a class="btn btn-primary" href="${this.getSpacePrefix()}/rcart/request">+ Create Payment Request</a>
|
<a class="btn btn-primary" href="${this.getSpacePrefix()}/rcart/request">+ Create Payment Request</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
|
||||||
<button class="tab ${this.activeTab === 'in' ? 'active' : ''}" data-tab="in">
|
|
||||||
Payments In
|
|
||||||
${this.payments.length > 0 ? `<span class="tab-count">${this.payments.length}</span>` : ''}
|
|
||||||
</button>
|
|
||||||
<button class="tab ${this.activeTab === 'out' ? 'active' : ''}" data-tab="out">Payments Out</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
${this.activeTab === 'in' ? this.renderPaymentsIn() : this.renderPaymentsOut()}
|
${this.renderPaymentsIn()}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
|
@ -196,14 +183,6 @@ class FolkPaymentsDashboard extends HTMLElement {
|
||||||
</a>`;
|
</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderPaymentsOut(): string {
|
|
||||||
return `<div class="empty-state">
|
|
||||||
<div class="empty-icon">🚀</div>
|
|
||||||
<p class="empty-title">Coming soon</p>
|
|
||||||
<p class="empty-desc">Outbound payment tracking will show on-chain transactions from your wallet.</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getStatusClass(status: string): string {
|
private getStatusClass(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending': return 'status-pending';
|
case 'pending': return 'status-pending';
|
||||||
|
|
@ -216,13 +195,6 @@ class FolkPaymentsDashboard extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindEvents() {
|
private bindEvents() {
|
||||||
this.shadow.querySelectorAll('[data-tab]').forEach(el => {
|
|
||||||
el.addEventListener('click', () => {
|
|
||||||
this.activeTab = (el as HTMLElement).dataset.tab as 'in' | 'out';
|
|
||||||
this.render();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.shadow.querySelector('[data-action="retry"]')?.addEventListener('click', () => this.fetchPayments());
|
this.shadow.querySelector('[data-action="retry"]')?.addEventListener('click', () => this.fetchPayments());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,13 +212,6 @@ class FolkPaymentsDashboard extends HTMLElement {
|
||||||
.btn-primary:hover { background: #4338ca; }
|
.btn-primary:hover { background: #4338ca; }
|
||||||
.btn-sm { padding: 0.25rem 0.625rem; font-size: 0.75rem; }
|
.btn-sm { padding: 0.25rem 0.625rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
.tabs { display: flex; border-bottom: 1px solid var(--rs-border); margin-bottom: 1rem; gap: 0; }
|
|
||||||
.tab { padding: 0.625rem 1rem; border: none; background: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.875rem; font-weight: 500; border-bottom: 2px solid transparent; transition: all 0.15s; display: flex; align-items: center; gap: 0.375rem; }
|
|
||||||
.tab:hover { color: var(--rs-text-primary); }
|
|
||||||
.tab.active { color: var(--rs-text-primary); border-bottom-color: var(--rs-primary-hover); }
|
|
||||||
.tab-count { background: var(--rs-bg-hover); color: var(--rs-text-secondary); padding: 0.0625rem 0.375rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 600; }
|
|
||||||
.tab.active .tab-count { background: rgba(99,102,241,0.15); color: var(--rs-primary-hover); }
|
|
||||||
|
|
||||||
.loading { text-align: center; color: var(--rs-text-secondary); padding: 3rem 1rem; font-size: 0.9375rem; }
|
.loading { text-align: center; color: var(--rs-text-secondary); padding: 3rem 1rem; font-size: 0.9375rem; }
|
||||||
|
|
||||||
.error-msg { color: #f87171; text-align: center; padding: 2rem 1rem; font-size: 0.875rem; display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
|
.error-msg { color: #f87171; text-align: center; padding: 2rem 1rem; font-size: 0.875rem; display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
|
||||||
|
|
@ -283,7 +248,6 @@ class FolkPaymentsDashboard extends HTMLElement {
|
||||||
:host { padding: 1rem 0.75rem; }
|
:host { padding: 1rem 0.75rem; }
|
||||||
.title { font-size: 1.25rem; }
|
.title { font-size: 1.25rem; }
|
||||||
.header { flex-direction: column; align-items: stretch; }
|
.header { flex-direction: column; align-items: stretch; }
|
||||||
.tab { font-size: 0.8125rem; padding: 0.5rem 0.75rem; }
|
|
||||||
}
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
:host { padding: 0.75rem 0.5rem; }
|
:host { padding: 0.75rem 0.5rem; }
|
||||||
|
|
|
||||||
|
|
@ -1,551 +0,0 @@
|
||||||
/* rSchedule 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
|
|
@ -1,293 +0,0 @@
|
||||||
/**
|
|
||||||
* <folk-reminders-widget> — lightweight sidebar widget for upcoming reminders.
|
|
||||||
*
|
|
||||||
* Fetches upcoming reminders from rSchedule 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]}/rschedule` : "/rschedule";
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +0,0 @@
|
||||||
/* rSchedule module — dark theme */
|
|
||||||
folk-schedule-app {
|
|
||||||
display: block;
|
|
||||||
min-height: 400px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
/**
|
|
||||||
* rSchedule landing page — persistent job scheduling for rSpace.
|
|
||||||
*/
|
|
||||||
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>on (you)rSchedule.
|
|
||||||
</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">
|
|
||||||
rSchedule 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,'rschedule');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,'rschedule')+'/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 + '/rschedule/';
|
|
||||||
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 scheduling 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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
/**
|
|
||||||
* rSchedule Local-First Client
|
|
||||||
*
|
|
||||||
* Wraps the shared local-first stack for collaborative schedule 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 { scheduleSchema, scheduleDocId, MAX_LOG_ENTRIES } from './schemas';
|
|
||||||
import type { ScheduleDoc, ScheduleJob, Reminder, Workflow, ExecutionLogEntry } from './schemas';
|
|
||||||
|
|
||||||
export class ScheduleLocalFirstClient {
|
|
||||||
#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(scheduleSchema);
|
|
||||||
}
|
|
||||||
|
|
||||||
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('schedule', 'jobs');
|
|
||||||
const cached = await this.#store.loadMany(cachedIds);
|
|
||||||
for (const [docId, binary] of cached) {
|
|
||||||
this.#documents.open<ScheduleDoc>(docId, scheduleSchema, 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('[ScheduleClient] Working offline'); }
|
|
||||||
this.#initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async subscribe(): Promise<ScheduleDoc | null> {
|
|
||||||
const docId = scheduleDocId(this.#space) as DocumentId;
|
|
||||||
let doc = this.#documents.get<ScheduleDoc>(docId);
|
|
||||||
if (!doc) {
|
|
||||||
const binary = await this.#store.load(docId);
|
|
||||||
doc = binary
|
|
||||||
? this.#documents.open<ScheduleDoc>(docId, scheduleSchema, binary)
|
|
||||||
: this.#documents.open<ScheduleDoc>(docId, scheduleSchema);
|
|
||||||
}
|
|
||||||
await this.#sync.subscribe([docId]);
|
|
||||||
return doc ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getDoc(): ScheduleDoc | undefined {
|
|
||||||
return this.#documents.get<ScheduleDoc>(scheduleDocId(this.#space) as DocumentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange(cb: (doc: ScheduleDoc) => void): () => void {
|
|
||||||
return this.#sync.onChange(scheduleDocId(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 = scheduleDocId(this.#space) as DocumentId;
|
|
||||||
this.#sync.change<ScheduleDoc>(docId, `Save job ${job.name}`, (d) => {
|
|
||||||
d.jobs[job.id] = job;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteJob(jobId: string): void {
|
|
||||||
const docId = scheduleDocId(this.#space) as DocumentId;
|
|
||||||
this.#sync.change<ScheduleDoc>(docId, `Delete job`, (d) => {
|
|
||||||
delete d.jobs[jobId];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleJob(jobId: string, enabled: boolean): void {
|
|
||||||
const docId = scheduleDocId(this.#space) as DocumentId;
|
|
||||||
this.#sync.change<ScheduleDoc>(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 = scheduleDocId(this.#space) as DocumentId;
|
|
||||||
this.#sync.change<ScheduleDoc>(docId, `Save reminder ${reminder.title}`, (d) => {
|
|
||||||
d.reminders[reminder.id] = reminder;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteReminder(reminderId: string): void {
|
|
||||||
const docId = scheduleDocId(this.#space) as DocumentId;
|
|
||||||
this.#sync.change<ScheduleDoc>(docId, `Delete reminder`, (d) => {
|
|
||||||
delete d.reminders[reminderId];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
completeReminder(reminderId: string): void {
|
|
||||||
const docId = scheduleDocId(this.#space) as DocumentId;
|
|
||||||
this.#sync.change<ScheduleDoc>(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 = scheduleDocId(this.#space) as DocumentId;
|
|
||||||
this.#sync.change<ScheduleDoc>(docId, `Save workflow ${workflow.name}`, (d) => {
|
|
||||||
d.workflows[workflow.id] = workflow;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteWorkflow(workflowId: string): void {
|
|
||||||
const docId = scheduleDocId(this.#space) as DocumentId;
|
|
||||||
this.#sync.change<ScheduleDoc>(docId, `Delete workflow`, (d) => {
|
|
||||||
delete d.workflows[workflowId];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Execution Log ──
|
|
||||||
|
|
||||||
appendLogEntry(entry: ExecutionLogEntry): void {
|
|
||||||
const docId = scheduleDocId(this.#space) as DocumentId;
|
|
||||||
this.#sync.change<ScheduleDoc>(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
|
|
@ -1,415 +0,0 @@
|
||||||
/**
|
|
||||||
* rSchedule Automerge document schemas.
|
|
||||||
*
|
|
||||||
* Granularity: one Automerge document per space (all jobs + execution log).
|
|
||||||
* DocId format: {space}:schedule: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 ScheduleDoc {
|
|
||||||
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 scheduleSchema: DocSchema<ScheduleDoc> = {
|
|
||||||
module: 'schedule',
|
|
||||||
collection: 'jobs',
|
|
||||||
version: 1,
|
|
||||||
init: (): ScheduleDoc => ({
|
|
||||||
meta: {
|
|
||||||
module: 'schedule',
|
|
||||||
collection: 'jobs',
|
|
||||||
version: 1,
|
|
||||||
spaceSlug: '',
|
|
||||||
createdAt: Date.now(),
|
|
||||||
},
|
|
||||||
jobs: {},
|
|
||||||
reminders: {},
|
|
||||||
workflows: {},
|
|
||||||
log: [],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Helpers ──
|
|
||||||
|
|
||||||
export function scheduleDocId(space: string) {
|
|
||||||
return `${space}:schedule: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;
|
|
||||||
|
|
@ -82,7 +82,7 @@ import { chatsModule } from "../modules/rchats/mod";
|
||||||
import { agentsModule } from "../modules/ragents/mod";
|
import { agentsModule } from "../modules/ragents/mod";
|
||||||
import { docsModule } from "../modules/rdocs/mod";
|
import { docsModule } from "../modules/rdocs/mod";
|
||||||
import { designModule } from "../modules/rdesign/mod";
|
import { designModule } from "../modules/rdesign/mod";
|
||||||
import { scheduleModule } from "../modules/rschedule/mod";
|
import { mindersModule } from "../modules/rminders/mod";
|
||||||
import { bnbModule } from "../modules/rbnb/mod";
|
import { bnbModule } from "../modules/rbnb/mod";
|
||||||
import { vnbModule } from "../modules/rvnb/mod";
|
import { vnbModule } from "../modules/rvnb/mod";
|
||||||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||||||
|
|
@ -167,7 +167,7 @@ registerModule(dataModule);
|
||||||
registerModule(splatModule);
|
registerModule(splatModule);
|
||||||
registerModule(photosModule);
|
registerModule(photosModule);
|
||||||
registerModule(socialsModule);
|
registerModule(socialsModule);
|
||||||
registerModule(scheduleModule);
|
registerModule(mindersModule);
|
||||||
registerModule(meetsModule);
|
registerModule(meetsModule);
|
||||||
registerModule(chatsModule);
|
registerModule(chatsModule);
|
||||||
registerModule(agentsModule);
|
registerModule(agentsModule);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* MCP tools for rSchedule (cron jobs, reminders, workflows).
|
* MCP tools for rMinders (cron jobs, reminders, workflows).
|
||||||
*
|
*
|
||||||
* Tools: rschedule_list_jobs, rschedule_list_reminders,
|
* Tools: rminders_list_jobs, rminders_list_reminders,
|
||||||
* rschedule_list_workflows, rschedule_create_reminder
|
* rminders_list_workflows, rminders_create_reminder
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { SyncServer } from "../local-first/sync-server";
|
import type { SyncServer } from "../local-first/sync-server";
|
||||||
import { scheduleDocId } from "../../modules/rschedule/schemas";
|
import { mindersDocId } from "../../modules/rminders/schemas";
|
||||||
import type { ScheduleDoc } from "../../modules/rschedule/schemas";
|
import type { MindersDoc } from "../../modules/rminders/schemas";
|
||||||
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
||||||
|
|
||||||
export function registerScheduleTools(server: McpServer, syncServer: SyncServer) {
|
export function registerMindersTools(server: McpServer, syncServer: SyncServer) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"rschedule_list_jobs",
|
"rminders_list_jobs",
|
||||||
"List cron/scheduled jobs in a space",
|
"List cron/scheduled jobs in a space",
|
||||||
{
|
{
|
||||||
space: z.string().describe("Space slug"),
|
space: z.string().describe("Space slug"),
|
||||||
|
|
@ -25,7 +25,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
|
||||||
const access = await resolveAccess(token, space, false);
|
const access = await resolveAccess(token, space, false);
|
||||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||||
|
|
||||||
const doc = syncServer.getDoc<ScheduleDoc>(scheduleDocId(space));
|
const doc = syncServer.getDoc<MindersDoc>(mindersDocId(space));
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] };
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +52,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"rschedule_list_reminders",
|
"rminders_list_reminders",
|
||||||
"List reminders in a space",
|
"List reminders in a space",
|
||||||
{
|
{
|
||||||
space: z.string().describe("Space slug"),
|
space: z.string().describe("Space slug"),
|
||||||
|
|
@ -65,7 +65,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
|
||||||
const access = await resolveAccess(token, space, false);
|
const access = await resolveAccess(token, space, false);
|
||||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||||
|
|
||||||
const doc = syncServer.getDoc<ScheduleDoc>(scheduleDocId(space));
|
const doc = syncServer.getDoc<MindersDoc>(mindersDocId(space));
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] };
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +102,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"rschedule_list_workflows",
|
"rminders_list_workflows",
|
||||||
"List automation workflows in a space (summaries only, omits node/edge graph)",
|
"List automation workflows in a space (summaries only, omits node/edge graph)",
|
||||||
{
|
{
|
||||||
space: z.string().describe("Space slug"),
|
space: z.string().describe("Space slug"),
|
||||||
|
|
@ -112,7 +112,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
|
||||||
const access = await resolveAccess(token, space, false);
|
const access = await resolveAccess(token, space, false);
|
||||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||||
|
|
||||||
const doc = syncServer.getDoc<ScheduleDoc>(scheduleDocId(space));
|
const doc = syncServer.getDoc<MindersDoc>(mindersDocId(space));
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] };
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +135,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"rschedule_create_reminder",
|
"rminders_create_reminder",
|
||||||
"Create a new reminder (requires auth token + space membership)",
|
"Create a new reminder (requires auth token + space membership)",
|
||||||
{
|
{
|
||||||
space: z.string().describe("Space slug"),
|
space: z.string().describe("Space slug"),
|
||||||
|
|
@ -151,8 +151,8 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
|
||||||
const access = await resolveAccess(token, space, true);
|
const access = await resolveAccess(token, space, true);
|
||||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||||
|
|
||||||
const docId = scheduleDocId(space);
|
const docId = mindersDocId(space);
|
||||||
const doc = syncServer.getDoc<ScheduleDoc>(docId);
|
const doc = syncServer.getDoc<MindersDoc>(docId);
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }], isError: true };
|
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }], isError: true };
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +160,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
|
||||||
const reminderId = `rem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const reminderId = `rem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
syncServer.changeDoc<ScheduleDoc>(docId, `Create reminder ${title}`, (d) => {
|
syncServer.changeDoc<MindersDoc>(docId, `Create reminder ${title}`, (d) => {
|
||||||
if (!d.reminders) (d as any).reminders = {};
|
if (!d.reminders) (d as any).reminders = {};
|
||||||
d.reminders[reminderId] = {
|
d.reminders[reminderId] = {
|
||||||
id: reminderId,
|
id: reminderId,
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
* Any module card/list-item can become draggable by calling
|
* Any module card/list-item can become draggable by calling
|
||||||
* `makeDraggable(el, payload)` — the calendar and reminders widget
|
* `makeDraggable(el, payload)` — the calendar and reminders widget
|
||||||
* will accept the drop via `application/rspace-item`.
|
* will accept the drop via `application/rspace-item`.
|
||||||
|
*
|
||||||
|
* Uses native HTML5 drag for mouse, and a PointerEvent-based fallback
|
||||||
|
* for touch/pen that synthesizes real DragEvents with a constructed
|
||||||
|
* DataTransfer — drop receivers don't need to change.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface RSpaceItemPayload {
|
export interface RSpaceItemPayload {
|
||||||
|
|
@ -28,13 +32,19 @@ export const MODULE_COLORS: Record<string, string> = {
|
||||||
rtube: "#a855f7", // purple
|
rtube: "#a855f7", // purple
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TOUCH_DRAG_THRESHOLD = 8; // px before a touch becomes a drag
|
||||||
|
const TOUCH_DRAG_LONGPRESS_MS = 350;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make an element draggable with the rspace-item protocol.
|
* Make an element draggable with the rspace-item protocol.
|
||||||
* Adds draggable attribute and dragstart handler.
|
* Adds draggable attribute and dragstart handler for mouse, plus a
|
||||||
|
* pointer-based touch/pen fallback that fires synthetic DragEvents.
|
||||||
*/
|
*/
|
||||||
export function makeDraggable(el: HTMLElement, payload: RSpaceItemPayload) {
|
export function makeDraggable(el: HTMLElement, payload: RSpaceItemPayload) {
|
||||||
el.draggable = true;
|
el.draggable = true;
|
||||||
el.style.cursor = "grab";
|
el.style.cursor = "grab";
|
||||||
|
|
||||||
|
// Native HTML5 drag (mouse / desktop trackpad)
|
||||||
el.addEventListener("dragstart", (e) => {
|
el.addEventListener("dragstart", (e) => {
|
||||||
if (!e.dataTransfer) return;
|
if (!e.dataTransfer) return;
|
||||||
e.dataTransfer.setData("application/rspace-item", JSON.stringify(payload));
|
e.dataTransfer.setData("application/rspace-item", JSON.stringify(payload));
|
||||||
|
|
@ -45,6 +55,9 @@ export function makeDraggable(el: HTMLElement, payload: RSpaceItemPayload) {
|
||||||
el.addEventListener("dragend", () => {
|
el.addEventListener("dragend", () => {
|
||||||
el.style.opacity = "";
|
el.style.opacity = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pointer-based fallback for touch + pen (iOS/iPadOS/Android)
|
||||||
|
attachPointerDragFallback(el, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -62,3 +75,189 @@ export function makeDraggableAll(
|
||||||
if (payload) makeDraggable(el, payload);
|
if (payload) makeDraggable(el, payload);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pointer-based drag fallback ──────────────────────────────────────
|
||||||
|
|
||||||
|
interface TouchDragState {
|
||||||
|
el: HTMLElement;
|
||||||
|
payload: RSpaceItemPayload;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
started: boolean;
|
||||||
|
pointerId: number;
|
||||||
|
dataTransfer: DataTransfer | null;
|
||||||
|
ghost: HTMLElement | null;
|
||||||
|
lastTarget: Element | null;
|
||||||
|
longPressTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachPointerDragFallback(el: HTMLElement, payload: RSpaceItemPayload) {
|
||||||
|
let state: TouchDragState | null = null;
|
||||||
|
|
||||||
|
el.addEventListener("pointerdown", (e) => {
|
||||||
|
// Mouse goes through native HTML5 drag path — skip.
|
||||||
|
if (e.pointerType === "mouse") return;
|
||||||
|
// Only primary button
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
// If inside an editable surface, don't hijack
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest("input, textarea, [contenteditable='true']")) return;
|
||||||
|
|
||||||
|
state = {
|
||||||
|
el,
|
||||||
|
payload,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
started: false,
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
dataTransfer: null,
|
||||||
|
ghost: null,
|
||||||
|
lastTarget: null,
|
||||||
|
longPressTimer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Long-press alternative: start drag after 350ms even without movement
|
||||||
|
state.longPressTimer = setTimeout(() => {
|
||||||
|
if (state && !state.started) beginPointerDrag(state, e.clientX, e.clientY);
|
||||||
|
}, TOUCH_DRAG_LONGPRESS_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", (e) => {
|
||||||
|
if (!state || e.pointerId !== state.pointerId) return;
|
||||||
|
if (!state.started) {
|
||||||
|
const dx = e.clientX - state.startX;
|
||||||
|
const dy = e.clientY - state.startY;
|
||||||
|
if (Math.hypot(dx, dy) < TOUCH_DRAG_THRESHOLD) return;
|
||||||
|
beginPointerDrag(state, e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
if (!state.started) return;
|
||||||
|
|
||||||
|
// Prevent scrolling while dragging
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Move ghost
|
||||||
|
if (state.ghost) {
|
||||||
|
state.ghost.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find current drop target (hide ghost briefly so it doesn't block elementFromPoint)
|
||||||
|
const ghost = state.ghost;
|
||||||
|
if (ghost) ghost.style.display = "none";
|
||||||
|
const under = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
if (ghost) ghost.style.display = "";
|
||||||
|
|
||||||
|
if (under !== state.lastTarget) {
|
||||||
|
// dragleave on previous
|
||||||
|
if (state.lastTarget && state.dataTransfer) {
|
||||||
|
dispatchDragEvent(state.lastTarget, "dragleave", state.dataTransfer, e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
state.lastTarget = under;
|
||||||
|
if (under && state.dataTransfer) {
|
||||||
|
dispatchDragEvent(under, "dragenter", state.dataTransfer, e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.lastTarget && state.dataTransfer) {
|
||||||
|
dispatchDragEvent(state.lastTarget, "dragover", state.dataTransfer, e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
const finish = (e: PointerEvent) => {
|
||||||
|
if (!state || e.pointerId !== state.pointerId) return;
|
||||||
|
if (state.longPressTimer) clearTimeout(state.longPressTimer);
|
||||||
|
|
||||||
|
if (state.started) {
|
||||||
|
// Dispatch drop on current target
|
||||||
|
if (state.lastTarget && state.dataTransfer) {
|
||||||
|
dispatchDragEvent(state.lastTarget, "drop", state.dataTransfer, e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
// Dispatch dragend on source
|
||||||
|
if (state.dataTransfer) {
|
||||||
|
dispatchDragEvent(state.el, "dragend", state.dataTransfer, e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
state.el.style.opacity = "";
|
||||||
|
if (state.ghost) state.ghost.remove();
|
||||||
|
}
|
||||||
|
state = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointerup", finish);
|
||||||
|
window.addEventListener("pointercancel", finish);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginPointerDrag(state: TouchDragState, x: number, y: number) {
|
||||||
|
state.started = true;
|
||||||
|
state.el.style.opacity = "0.4";
|
||||||
|
|
||||||
|
// Build a real DataTransfer so existing dragover/drop listeners that
|
||||||
|
// call `e.dataTransfer.getData('application/rspace-item')` work unchanged.
|
||||||
|
let dt: DataTransfer;
|
||||||
|
try {
|
||||||
|
dt = new DataTransfer();
|
||||||
|
dt.setData("application/rspace-item", JSON.stringify(state.payload));
|
||||||
|
dt.setData("text/plain", state.payload.title);
|
||||||
|
dt.effectAllowed = "copyMove";
|
||||||
|
} catch {
|
||||||
|
// Older Safari fallback — we'll still dispatch events, but dataTransfer.getData
|
||||||
|
// may not work. Consumers can read from (event as any).rspaceItemPayload.
|
||||||
|
dt = new DataTransfer();
|
||||||
|
}
|
||||||
|
state.dataTransfer = dt;
|
||||||
|
|
||||||
|
// Let source's existing `dragstart` listeners set extra payloads
|
||||||
|
// (e.g. rdocs `application/x-rdocs-move`).
|
||||||
|
dispatchDragEvent(state.el, "dragstart", dt, x, y);
|
||||||
|
|
||||||
|
// Build drag ghost (floating preview of the source element)
|
||||||
|
const ghost = document.createElement("div");
|
||||||
|
ghost.textContent = state.payload.title;
|
||||||
|
ghost.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform: translate(${x}px, ${y}px);
|
||||||
|
z-index: 100000;
|
||||||
|
pointer-events: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: ${state.payload.color || "#3b82f6"};
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||||
|
opacity: 0.92;
|
||||||
|
max-width: 260px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(ghost);
|
||||||
|
state.ghost = ghost;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchDragEvent(
|
||||||
|
target: Element | EventTarget,
|
||||||
|
type: string,
|
||||||
|
dataTransfer: DataTransfer,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
): void {
|
||||||
|
let ev: DragEvent;
|
||||||
|
try {
|
||||||
|
ev = new DragEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
dataTransfer,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Older browsers: fall back to CustomEvent with dataTransfer attached
|
||||||
|
const fallback = new Event(type, { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(fallback, "dataTransfer", { value: dataTransfer, enumerable: true });
|
||||||
|
Object.defineProperty(fallback, "clientX", { value: clientX, enumerable: true });
|
||||||
|
Object.defineProperty(fallback, "clientY", { value: clientY, enumerable: true });
|
||||||
|
target.dispatchEvent(fallback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue