feat: add universal reminders system with calendar integration & cross-module drag

Add a Reminders subsystem to rSchedule that lets users create date-based
reminders (free-form or linked to cross-module items), receive email
notifications via the existing tick loop, and sync bidirectionally with
rCal calendar events. Includes drag-and-drop from rWork task cards onto
calendar day cells to create reminders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 17:37:54 -08:00
parent da2b21cd98
commit bb052c49d3
7 changed files with 1670 additions and 21 deletions

View File

@ -14,8 +14,12 @@ const TEMPORAL_LABELS = ["Moment","Hour","Day","Week","Month","Season","Year","D
const SPATIAL_LABELS = ["Planet","Continent","Bioregion","Country","Region","City","Neighborhood","Address","Coordinates"];
const T_TO_S = [8, 7, 7, 5, 3, 3, 1, 1, 0, 0]; // temporal → spatial coupling
const S_TO_ZOOM = [2, 4, 5, 6, 8, 11, 14, 16, 18]; // spatial → Leaflet zoom
const T_TO_VIEW: Record<number, "day"|"week"|"month"|"season"|"year"> = {
2: "day", 3: "week", 4: "month", 5: "season", 6: "year"
const T_TO_VIEW: Record<number, "day"|"week"|"month"|"season"|"year"|"multi-year"> = {
2: "day", 3: "week", 4: "month", 5: "season", 6: "year", 7: "multi-year"
};
const VIEW_VARIANTS: Record<string, number> = {
day: 2, week: 1, month: 2, season: 1, year: 2, "multi-year": 1
};
// ── Leaflet CDN Loader ──
@ -102,7 +106,7 @@ class FolkCalendarView extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private currentDate = new Date();
private viewMode: "month" | "week" | "day" | "season" | "year" = "month";
private viewMode: "month" | "week" | "day" | "season" | "year" | "multi-year" = "month";
private events: any[] = [];
private sources: any[] = [];
private lunarData: Record<string, { phase: string; illumination: number }> = {};
@ -124,6 +128,12 @@ class FolkCalendarView extends HTMLElement {
private lunarOverlayExpanded = false;
private _wheelTimer: ReturnType<typeof setTimeout> | null = null;
// Transition state
private _pendingTransition: 'nav-left' | 'nav-right' | 'zoom-in' | 'zoom-out' | null = null;
private _ghostHtml: string | null = null;
private _transitionActive = false;
private viewVariant = 0;
// Leaflet map (preserved across re-renders)
private leafletMap: any = null;
private mapContainer: HTMLDivElement | null = null;
@ -169,6 +179,15 @@ class FolkCalendarView extends HTMLElement {
case "3": this.setTemporalGranularity(4); break;
case "4": this.setTemporalGranularity(5); break;
case "5": this.setTemporalGranularity(6); break;
case "6": this.setTemporalGranularity(7); break;
case "v": case "V": {
const maxVariant = VIEW_VARIANTS[this.viewMode] || 1;
if (maxVariant > 1) {
this.viewVariant = (this.viewVariant + 1) % maxVariant;
this.render();
}
break;
}
case "t": case "T":
this.currentDate = new Date(); this.expandedDay = "";
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
@ -194,7 +213,14 @@ class FolkCalendarView extends HTMLElement {
// ── Zoom & Coupling ──
private setTemporalGranularity(n: number) {
n = Math.max(2, Math.min(6, n));
const oldGranularity = this.temporalGranularity;
n = Math.max(2, Math.min(7, n));
if (n !== oldGranularity) {
const calPane = this.shadow.getElementById('calendar-pane');
this._ghostHtml = calPane?.innerHTML || null;
this._pendingTransition = n < oldGranularity ? 'zoom-in' : 'zoom-out';
this.viewVariant = 0;
}
this.temporalGranularity = n;
const view = T_TO_VIEW[n];
if (view) this.viewMode = view;
@ -204,7 +230,7 @@ class FolkCalendarView extends HTMLElement {
}
private zoomIn() { this.setTemporalGranularity(this.temporalGranularity - 1); }
private zoomOut() { this.setTemporalGranularity(this.temporalGranularity + 1); }
private zoomOut() { if (this.temporalGranularity < 7) this.setTemporalGranularity(this.temporalGranularity + 1); }
private getEffectiveSpatialIndex(): number {
if (this.zoomCoupled) return T_TO_S[this.temporalGranularity];
@ -384,6 +410,10 @@ class FolkCalendarView extends HTMLElement {
// ── Navigation ──
private navigate(delta: number) {
const calPane = this.shadow.getElementById('calendar-pane');
this._ghostHtml = calPane?.innerHTML || null;
this._pendingTransition = delta > 0 ? 'nav-left' : 'nav-right';
switch (this.viewMode) {
case "day":
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta);
@ -400,6 +430,9 @@ class FolkCalendarView extends HTMLElement {
case "year":
this.currentDate = new Date(this.currentDate.getFullYear() + delta, this.currentDate.getMonth(), 1);
break;
case "multi-year":
this.currentDate = new Date(this.currentDate.getFullYear() + delta * 9, this.currentDate.getMonth(), 1);
break;
}
this.expandedDay = "";
if (this.space !== "demo") { this.loadMonth(); } else { this.render(); }
@ -429,6 +462,48 @@ class FolkCalendarView extends HTMLElement {
this.render();
}
// ── Transitions ──
private playTransition(calPane: HTMLElement, direction: string, oldHtml: string) {
if (this._transitionActive) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
this._transitionActive = true;
// Create ghost overlay with old content
const ghost = document.createElement('div');
ghost.className = 'transition-ghost';
ghost.innerHTML = oldHtml;
// Wrap new content in enter wrapper
const enterWrap = document.createElement('div');
enterWrap.className = 'transition-enter';
while (calPane.firstChild) enterWrap.appendChild(calPane.firstChild);
calPane.appendChild(enterWrap);
calPane.appendChild(ghost);
// Apply direction-based animation classes
const animMap: Record<string, [string, string]> = {
'nav-left': ['ghost-slide-left', 'enter-slide-left'],
'nav-right': ['ghost-slide-right', 'enter-slide-right'],
'zoom-in': ['ghost-zoom-in', 'enter-zoom-in'],
'zoom-out': ['ghost-zoom-out', 'enter-zoom-out'],
};
const [ghostAnim, enterAnim] = animMap[direction] || animMap['nav-left'];
ghost.classList.add(ghostAnim);
enterWrap.classList.add(enterAnim);
const cleanup = () => {
if (!ghost.parentNode) return;
ghost.remove();
while (enterWrap.firstChild) calPane.insertBefore(enterWrap.firstChild, enterWrap);
enterWrap.remove();
this._transitionActive = false;
};
ghost.addEventListener('animationend', cleanup, { once: true });
setTimeout(cleanup, 400); // safety fallback
}
// ── Main Render ──
private render() {
@ -470,7 +545,8 @@ class FolkCalendarView extends HTMLElement {
<kbd>+</kbd>/<kbd>-</kbd> zoom &bull;
<kbd>\u2190</kbd>/<kbd>\u2192</kbd> nav &bull;
<kbd>t</kbd> today &bull;
<kbd>1-5</kbd> view &bull;
<kbd>1-6</kbd> view &bull;
<kbd>v</kbd> variant &bull;
<kbd>m</kbd> map &bull;
<kbd>c</kbd> coupling &bull;
<kbd>l</kbd> lunar &bull;
@ -482,6 +558,14 @@ class FolkCalendarView extends HTMLElement {
this.attachListeners();
// Play transition if pending
if (this._pendingTransition && this._ghostHtml) {
const calPaneEl = this.shadow.getElementById('calendar-pane');
if (calPaneEl) this.playTransition(calPaneEl, this._pendingTransition, this._ghostHtml);
}
this._pendingTransition = null;
this._ghostHtml = null;
// Initialize or update map when not minimized
if (this.mapPanelState !== "minimized") {
this.initOrUpdateMap();
@ -506,6 +590,12 @@ class FolkCalendarView extends HTMLElement {
}
case "year":
return `${d.getFullYear()}`;
case "multi-year": {
const centerYear = d.getFullYear();
const startYear = centerYear - 4;
const endYear = centerYear + 4;
return `${startYear} \u2013 ${endYear}`;
}
default:
return d.toLocaleString("default", { month: "long", year: "numeric" });
}
@ -574,11 +664,13 @@ class FolkCalendarView extends HTMLElement {
private renderZoomController(): string {
const levels = [
{ idx: 2, label: "Day" }, { idx: 3, label: "Week" },
{ idx: 4, label: "Month" }, { idx: 5, label: "Season" }, { idx: 6, label: "Year" },
{ idx: 4, label: "Month" }, { idx: 5, label: "Season" },
{ idx: 6, label: "Year" }, { idx: 7, label: "Years" },
];
const canIn = this.temporalGranularity > 2;
const canOut = this.temporalGranularity < 6;
const canOut = this.temporalGranularity < 7;
const spatialLabel = SPATIAL_LABELS[this.getEffectiveSpatialIndex()];
const maxVariant = VIEW_VARIANTS[this.viewMode] || 1;
return `<div class="zoom-ctrl">
<button class="zoom-btn" id="zoom-in" ${!canIn ? "disabled" : ""} title="Zoom in (+)">+</button>
@ -589,6 +681,11 @@ class FolkCalendarView extends HTMLElement {
</div>`).join("")}
</div>
<button class="zoom-btn" id="zoom-out" ${!canOut ? "disabled" : ""} title="Zoom out (\u2212)">&#x2212;</button>
${maxVariant > 1 ? `<div class="variant-indicator" title="Press v to toggle variant">
${Array.from({length: maxVariant}, (_, i) =>
`<span class="variant-dot ${i === this.viewVariant ? "active" : ""}"></span>`
).join("")}
</div>` : ""}
<button class="coupling-btn ${this.zoomCoupled ? "coupled" : ""}" id="toggle-coupling"
title="${this.zoomCoupled ? "Unlink spatial zoom (c)" : "Link spatial zoom (c)"}">
${this.zoomCoupled ? "\u{1F517}" : "\u{1F513}"} ${spatialLabel}
@ -637,11 +734,12 @@ class FolkCalendarView extends HTMLElement {
private renderCalendarContent(): string {
switch (this.viewMode) {
case "day": return this.renderDay();
case "day": return this.viewVariant === 1 ? this.renderDayHorizontal() : this.renderDay();
case "week": return this.renderWeek();
case "month": return this.renderMonth();
case "month": return this.viewVariant === 1 ? this.renderMonthTransposed() : this.renderMonth();
case "season": return this.renderSeason();
case "year": return this.renderYear();
case "year": return this.viewVariant === 1 ? this.renderYearVertical() : this.renderYear();
case "multi-year": return this.renderMultiYear();
default: return this.renderMonth();
}
}
@ -679,7 +777,7 @@ class FolkCalendarView extends HTMLElement {
const dayEvents = this.getEventsForDate(ds);
const lunar = this.lunarData[ds];
html += `<div class="day ${isToday ? "today" : ""} ${isExpanded ? "expanded" : ""}" data-date="${ds}">
html += `<div class="day ${isToday ? "today" : ""} ${isExpanded ? "expanded" : ""}" data-date="${ds}" data-drop-date="${ds}">
<div class="day-num">
<span>${d}</span>
${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
@ -690,7 +788,7 @@ class FolkCalendarView extends HTMLElement {
${dayEvents.length > 4 ? `<span style="font-size:8px;color:#888">+${dayEvents.length - 4}</span>` : ""}
</div>
${dayEvents.slice(0, 2).map(e =>
`<div class="ev-label" style="border-left:2px solid ${e.source_color || "#6366f1"}" data-event-id="${e.id}"><span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}</div>`
`<div class="ev-label" style="border-left:2px solid ${e.source_color || "#6366f1"}" data-event-id="${e.id}">${e.rToolSource === "rSchedule" ? '<span class="ev-bell">&#128276;</span>' : ""}<span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}</div>`
).join("")}
` : ""}
</div>`;
@ -744,6 +842,202 @@ class FolkCalendarView extends HTMLElement {
</div>`;
}
// ── Day Horizontal View (variant 1) ──
private renderDayHorizontal(): string {
const ds = this.dateStr(this.currentDate);
const dayEvents = this.getEventsForDate(ds);
const lunar = this.lunarData[ds];
const now = new Date();
const isToday = this.dateStr(now) === ds;
const HOUR_WIDTH = 80;
const START_HOUR = 6;
const END_HOUR = 23;
const totalWidth = (END_HOUR - START_HOUR + 1) * HOUR_WIDTH;
const timed = dayEvents.filter(e => {
const s = new Date(e.start_time), en = new Date(e.end_time);
return (en.getTime() - s.getTime()) < 86400000;
}).sort((a, b) => a.start_time.localeCompare(b.start_time));
let hoursHtml = "";
for (let h = START_HOUR; h <= END_HOUR; h++) {
const label = h === 0 ? "12a" : h < 12 ? `${h}a` : h === 12 ? "12p" : `${h - 12}p`;
hoursHtml += `<div class="dh-hour" style="left:${(h - START_HOUR) * HOUR_WIDTH}px;width:${HOUR_WIDTH}px">${label}</div>`;
}
let eventsHtml = "";
for (const ev of timed) {
const start = new Date(ev.start_time), end = new Date(ev.end_time);
const startMin = start.getHours() * 60 + start.getMinutes();
const endMin = end.getHours() * 60 + end.getMinutes();
const duration = Math.max(endMin - startMin, 30);
const leftPx = ((startMin - START_HOUR * 60) / 60) * HOUR_WIDTH;
const widthPx = Math.max((duration / 60) * HOUR_WIDTH, 60);
const bgColor = ev.source_color ? `${ev.source_color}18` : "#6366f118";
eventsHtml += `<div class="dh-event" data-event-id="${ev.id}" style="
left:${leftPx}px;width:${widthPx}px;background:${bgColor};border-top:3px solid ${ev.source_color || "#6366f1"}">
<div class="tl-event-title">${this.esc(ev.title)}</div>
<div class="tl-event-time">${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}</div>
</div>`;
}
let nowHtml = "";
if (isToday) {
const nowMin = now.getHours() * 60 + now.getMinutes();
const nowPx = ((nowMin - START_HOUR * 60) / 60) * HOUR_WIDTH;
if (nowPx >= 0 && nowPx <= totalWidth) {
nowHtml = `<div class="dh-now" style="left:${nowPx}px"></div>`;
}
}
return `
<div class="day-view">
<div class="day-view-header">
${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)} ${Math.round(lunar.illumination * 100)}% illuminated \u00B7 ` : ""}
${dayEvents.length} event${dayEvents.length !== 1 ? "s" : ""} \u00B7 Horizontal
</div>
<div class="dh-container" style="width:${totalWidth}px">
<div class="dh-hours">${hoursHtml}</div>
<div class="dh-events">${eventsHtml}${nowHtml}</div>
</div>
</div>`;
}
// ── Month Transposed View (variant 1) ──
private renderMonthTransposed(): string {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = new Date();
const todayStr = this.dateStr(today);
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Group days by day-of-week, tracking which week they fall in
const firstDate = new Date(year, month, 1);
const firstDow = firstDate.getDay();
const numWeeks = Math.ceil((firstDow + daysInMonth) / 7);
// Build header: week numbers
let headerHtml = `<div class="mt-day-name"></div>`;
for (let w = 0; w < numWeeks; w++) {
// Get the Monday of this week for ISO week number
const weekStart = new Date(year, month, 1 - firstDow + w * 7);
const onejan = new Date(weekStart.getFullYear(), 0, 1);
const weekNum = Math.ceil(((weekStart.getTime() - onejan.getTime()) / 86400000 + onejan.getDay() + 1) / 7);
headerHtml += `<div class="mt-week-header">W${weekNum}</div>`;
}
// Build rows: one per day-of-week
let rowsHtml = "";
for (let dow = 0; dow < 7; dow++) {
rowsHtml += `<div class="mt-row"><div class="mt-day-name">${dayNames[dow]}</div>`;
for (let w = 0; w < numWeeks; w++) {
const dayOfMonth = 1 - firstDow + w * 7 + dow;
if (dayOfMonth < 1 || dayOfMonth > daysInMonth) {
rowsHtml += `<div class="mt-cell empty"></div>`;
continue;
}
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(dayOfMonth).padStart(2, "0")}`;
const isToday = ds === todayStr;
const dayEvents = this.getEventsForDate(ds);
const isWeekend = dow === 0 || dow === 6;
rowsHtml += `<div class="mt-cell ${isToday ? "today" : ""} ${isWeekend ? "weekend" : ""}" data-date="${ds}">
<span class="mt-num">${dayOfMonth}</span>
${dayEvents.length > 0 ? `<span class="mt-count" style="background:${dayEvents[0].source_color || "#6366f1"}">${dayEvents.length}</span>` : ""}
</div>`;
}
rowsHtml += `</div>`;
}
return `
<div class="month-transposed">
<div class="mt-header-row">${headerHtml}</div>
${rowsHtml}
</div>`;
}
// ── Year Vertical View (variant 1 — kalnext style) ──
private renderYearVertical(): string {
const year = this.currentDate.getFullYear();
const today = new Date();
const todayStr = this.dateStr(today);
let html = `<div class="year-vertical">`;
for (let m = 0; m < 12; m++) {
const monthName = new Date(year, m, 1).toLocaleDateString("default", { month: "short" });
const dim = new Date(year, m + 1, 0).getDate();
let daysHtml = "";
for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const isToday = ds === todayStr;
const dayEvents = this.getEventsForDate(ds);
const dow = new Date(year, m, d).getDay();
const isWeekend = dow === 0 || dow === 6;
daysHtml += `<div class="yv-day ${isToday ? "today" : ""} ${isWeekend ? "weekend" : ""}" data-mini-date="${ds}" title="${d} ${monthName}${dayEvents.length ? ` (${dayEvents.length} events)` : ""}">
${d}
${dayEvents.length > 0 ? `<span class="yv-dot" style="background:${dayEvents[0].source_color || "#6366f1"}"></span>` : ""}
</div>`;
}
html += `<div class="yv-month" data-mini-month="${m}">
<div class="yv-label">${monthName}</div>
<div class="yv-days">${daysHtml}</div>
</div>`;
}
html += `</div>`;
return html;
}
// ── Multi-Year View (3x3 grid) ──
private renderMultiYear(): string {
const centerYear = this.currentDate.getFullYear();
const startYear = centerYear - 4;
let html = `<div class="multi-year-grid">`;
for (let i = 0; i < 9; i++) {
const y = startYear + i;
const isCurrent = y === new Date().getFullYear();
let monthsHtml = "";
for (let m = 0; m < 12; m++) {
monthsHtml += this.renderMicroMonth(y, m);
}
html += `<div class="my-year ${isCurrent ? "current" : ""}" data-my-year="${y}">
<div class="my-year-label">${y}</div>
<div class="my-months">${monthsHtml}</div>
</div>`;
}
html += `</div>`;
return html;
}
private renderMicroMonth(year: number, month: number): string {
const monthInitials = ["J","F","M","A","M","J","J","A","S","O","N","D"];
const dim = new Date(year, month + 1, 0).getDate();
let eventCount = 0;
for (let d = 1; d <= dim; d++) {
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
eventCount += this.getEventsForDate(ds).length;
}
const maxBar = 8; // normalize bar width
const barWidth = Math.min(eventCount, maxBar);
const barPct = (barWidth / maxBar) * 100;
return `<div class="micro-month" data-micro-click="${year}-${month}" title="${new Date(year, month, 1).toLocaleDateString("default", { month: "long", year: "numeric" })}${eventCount ? ` (${eventCount} events)` : ""}">
<span class="micro-label">${monthInitials[month]}</span>
${eventCount > 0 ? `<span class="micro-bar" style="width:${barPct}%"></span>` : ""}
${eventCount > 0 ? `<span class="micro-count">${eventCount}</span>` : ""}
</div>`;
}
// ── Mini-Month (shared by Season & Year views) ──
private renderMiniMonth(year: number, month: number): string {
@ -967,6 +1261,70 @@ class FolkCalendarView extends HTMLElement {
</div>`;
}
// ── Reminder Drop Handler ──
private getScheduleApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)/);
return match ? `${match[1]}/rschedule` : "/rschedule";
}
private async handleReminderDrop(e: DragEvent, dropDate: string) {
let title = "";
let sourceModule: string | null = null;
let sourceEntityId: string | null = null;
let sourceLabel: string | null = null;
let sourceColor: string | null = null;
// Try cross-module format first
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 to text/plain */ }
}
// Fall back to plain text
if (!title) {
title = e.dataTransfer?.getData("text/plain") || "";
}
if (!title.trim()) return;
// Prompt for quick confirmation
const confirmed = confirm(`Create reminder "${title}" on ${dropDate}?`);
if (!confirmed) return;
const remindAt = new Date(dropDate + "T09:00:00").getTime();
const base = this.getScheduleApiBase();
try {
await fetch(`${base}/api/reminders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
remindAt,
allDay: true,
syncToCalendar: true,
sourceModule,
sourceEntityId,
sourceLabel,
sourceColor,
}),
});
// Reload events to show the new calendar entry
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
} catch (err) {
console.error("[rCal] Failed to create reminder:", err);
}
}
// ── Attach Listeners ──
private attachListeners() {
@ -1032,6 +1390,25 @@ class FolkCalendarView extends HTMLElement {
});
});
// Month view: drop-on-date for reminders
$$("[data-drop-date]").forEach(el => {
el.addEventListener("dragover", (e) => {
e.preventDefault();
(el as HTMLElement).classList.add("drop-target");
});
el.addEventListener("dragleave", () => {
(el as HTMLElement).classList.remove("drop-target");
});
el.addEventListener("drop", (e) => {
e.preventDefault();
e.stopPropagation();
(el as HTMLElement).classList.remove("drop-target");
const dropDate = (el as HTMLElement).dataset.dropDate;
if (!dropDate) return;
this.handleReminderDrop(e as DragEvent, dropDate);
});
});
// Week day header click → switch to day view
$$("[data-day-click]").forEach(el => {
el.addEventListener("click", () => {
@ -1085,6 +1462,37 @@ class FolkCalendarView extends HTMLElement {
});
$("modal-close")?.addEventListener("click", () => { this.selectedEvent = null; this.render(); });
// Multi-year: click year tile → zoom to year
$$("[data-my-year]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const y = parseInt((el as HTMLElement).dataset.myYear!);
this.currentDate = new Date(y, this.currentDate.getMonth(), 1);
this.setTemporalGranularity(6);
});
});
// Multi-year: click micro-month → zoom to month
$$("[data-micro-click]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const parts = (el as HTMLElement).dataset.microClick!.split("-");
this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]), 1);
this.setTemporalGranularity(4);
});
});
// Transposed month cell click → zoom to day
$$(".mt-cell:not(.empty)").forEach(el => {
el.addEventListener("click", () => {
const date = (el as HTMLElement).dataset.date;
if (!date) return;
const parts = date.split("-");
this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
this.setTemporalGranularity(2);
});
});
// Scroll/trackpad zoom on calendar pane
const calPane = $("calendar-pane");
if (calPane) {
@ -1308,6 +1716,10 @@ class FolkCalendarView extends HTMLElement {
.ev-label { font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #aaa; line-height: 1.4; padding: 1px 3px; border-radius: 3px; cursor: pointer; }
.ev-label:hover { background: rgba(255,255,255,0.08); }
.ev-time { color: #666; font-size: 8px; margin-right: 2px; }
.ev-bell { margin-right: 2px; font-size: 8px; }
/* ── Drop Target ── */
.day.drop-target { background: rgba(245,158,11,0.15); border: 2px dashed #f59e0b; }
/* ── Day Detail Panel ── */
.day-detail { grid-column: 1 / -1; background: #1a1a2e; border: 1px solid #334155; border-radius: 8px; padding: 12px; }
@ -1385,6 +1797,91 @@ class FolkCalendarView extends HTMLElement {
.mini-dot { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); width: 3px; height: 3px; border-radius: 50%; }
.mini-hint { text-align: center; font-size: 11px; color: #4a5568; margin-top: 8px; }
/* ── Transition Animations ── */
.calendar-pane { position: relative; overflow: hidden; }
.transition-ghost {
position: absolute; inset: 0; z-index: 10; pointer-events: none;
will-change: transform, opacity;
}
.transition-enter {
will-change: transform, opacity;
}
/* Ghost exits */
.ghost-slide-left { animation: ghostSlideLeft 280ms ease-out forwards; }
.ghost-slide-right { animation: ghostSlideRight 280ms ease-out forwards; }
.ghost-zoom-in { animation: ghostZoomIn 320ms ease-in-out forwards; }
.ghost-zoom-out { animation: ghostZoomOut 320ms ease-in-out forwards; }
/* New content enters */
.enter-slide-left { animation: enterSlideLeft 280ms ease-out forwards; }
.enter-slide-right { animation: enterSlideRight 280ms ease-out forwards; }
.enter-zoom-in { animation: enterZoomIn 320ms ease-in-out forwards; }
.enter-zoom-out { animation: enterZoomOut 320ms ease-in-out forwards; }
@keyframes ghostSlideLeft { from { transform: translateX(0); opacity: 1; } to { transform: translateX(-100%); opacity: 0; } }
@keyframes ghostSlideRight { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
@keyframes ghostZoomIn { from { transform: scale(1); opacity: 1; } to { transform: scale(1.3); opacity: 0; } }
@keyframes ghostZoomOut { from { transform: scale(1); opacity: 1; } to { transform: scale(0.7); opacity: 0; } }
@keyframes enterSlideLeft { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes enterSlideRight { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes enterZoomIn { from { transform: scale(0.7); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes enterZoomOut { from { transform: scale(1.3); opacity: 0; } to { transform: scale(1); opacity: 1; } }
/* ── Variant Indicator ── */
.variant-indicator { display: flex; gap: 4px; align-items: center; padding: 0 6px; }
.variant-dot { width: 6px; height: 6px; border-radius: 50%; border: 1px solid #555; background: transparent; transition: all 0.15s; }
.variant-dot.active { background: #6366f1; border-color: #6366f1; }
/* ── Horizontal Day View ── */
.dh-container { overflow-x: auto; overflow-y: hidden; position: relative; min-height: 180px; padding: 0 0 8px; }
.dh-hours { position: relative; height: 24px; border-bottom: 1px solid #222; }
.dh-hour { position: absolute; top: 0; height: 24px; font-size: 10px; color: #4a5568; text-align: center; border-left: 1px solid rgba(255,255,255,0.04); line-height: 24px; }
.dh-events { position: relative; min-height: 140px; padding-top: 8px; }
.dh-event { position: absolute; top: 32px; height: auto; min-height: 48px; border-radius: 6px; padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer; border-top: 3px solid; z-index: 1; }
.dh-event:hover { opacity: 0.85; }
.dh-now { position: absolute; top: 0; bottom: 0; width: 2px; background: #ef4444; z-index: 5; }
.dh-now::before { content: ""; position: absolute; top: -3px; left: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }
/* ── Month Transposed View ── */
.month-transposed { overflow-x: auto; }
.mt-header-row { display: flex; gap: 2px; margin-bottom: 4px; }
.mt-week-header { flex: 1; min-width: 48px; text-align: center; font-size: 10px; color: #4a5568; font-weight: 600; padding: 4px; }
.mt-row { display: flex; gap: 2px; margin-bottom: 2px; }
.mt-day-name { width: 40px; flex-shrink: 0; font-size: 11px; color: #64748b; font-weight: 600; display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; }
.mt-cell { flex: 1; min-width: 48px; min-height: 36px; background: #16161e; border: 1px solid #222; border-radius: 4px; display: flex; align-items: center; justify-content: center; gap: 4px; cursor: pointer; position: relative; }
.mt-cell:hover { border-color: #444; }
.mt-cell.today { border-color: #6366f1; background: rgba(99,102,241,0.06); }
.mt-cell.weekend { background: rgba(255,255,255,0.02); }
.mt-cell.empty { background: transparent; border-color: transparent; cursor: default; }
.mt-num { font-size: 12px; color: #94a3b8; font-weight: 500; }
.mt-count { font-size: 9px; color: #fff; padding: 1px 5px; border-radius: 8px; font-weight: 600; }
/* ── Year Vertical View ── */
.year-vertical { max-height: 600px; overflow-y: auto; }
.yv-month { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid #1a1a2e; cursor: pointer; }
.yv-month:hover { background: rgba(255,255,255,0.02); }
.yv-label { width: 36px; flex-shrink: 0; font-size: 11px; font-weight: 600; color: #94a3b8; text-align: right; padding-top: 2px; }
.yv-days { display: flex; flex-wrap: wrap; gap: 2px; flex: 1; }
.yv-day { position: relative; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; font-size: 9px; color: #64748b; border-radius: 3px; cursor: pointer; }
.yv-day:hover { background: rgba(255,255,255,0.08); color: #e2e8f0; }
.yv-day.today { background: #4f46e5; color: #fff; font-weight: 700; }
.yv-day.weekend { opacity: 0.5; }
.yv-dot { position: absolute; bottom: 1px; left: 50%; transform: translateX(-50%); width: 3px; height: 3px; border-radius: 50%; }
/* ── Multi-Year View ── */
.multi-year-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.my-year { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 8px; cursor: pointer; transition: border-color 0.15s; }
.my-year:hover { border-color: #444; }
.my-year.current { border-color: #6366f1; background: rgba(99,102,241,0.06); }
.my-year-label { font-size: 14px; font-weight: 700; text-align: center; color: #e2e8f0; margin-bottom: 6px; }
.my-months { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2px; }
.micro-month { display: flex; align-items: center; gap: 2px; padding: 2px 3px; border-radius: 3px; cursor: pointer; overflow: hidden; }
.micro-month:hover { background: rgba(255,255,255,0.08); }
.micro-label { font-size: 8px; color: #4a5568; font-weight: 600; width: 8px; flex-shrink: 0; }
.micro-bar { height: 3px; background: #6366f1; border-radius: 2px; flex-shrink: 0; }
.micro-count { font-size: 7px; color: #64748b; flex-shrink: 0; }
/* ── Keyboard Hint ── */
.kbd-hint { text-align: center; font-size: 10px; color: #333; margin-top: 12px; padding-top: 8px; border-top: 1px solid #1a1a2e; }
.kbd-hint kbd { padding: 1px 4px; background: #16161e; border: 1px solid #222; border-radius: 3px; font-family: inherit; font-size: 9px; }
@ -1413,6 +1910,9 @@ class FolkCalendarView extends HTMLElement {
.lunar-summary { gap: 8px; flex-wrap: wrap; }
.phase-chips { gap: 4px; }
.phase-chip { padding: 3px 8px; font-size: 10px; }
.multi-year-grid { grid-template-columns: repeat(3, 1fr); gap: 6px; }
.mt-cell { min-width: 36px; min-height: 30px; }
.mt-day-name { width: 30px; font-size: 10px; }
}
@media (max-width: 480px) {
.day { min-height: 44px; padding: 3px; }
@ -1421,6 +1921,7 @@ class FolkCalendarView extends HTMLElement {
.nav { flex-wrap: wrap; justify-content: center; }
.nav-title { width: 100%; order: -1; margin-bottom: 4px; }
.year-grid { grid-template-columns: repeat(2, 1fr); }
.multi-year-grid { grid-template-columns: repeat(2, 1fr); }
.mini-day { font-size: 8px; }
.mini-month-title { font-size: 10px; }
}

View File

@ -0,0 +1,283 @@
/**
* <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 = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.loadReminders();
}
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
private render() {
const styles = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
.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: #64748b; }
.rw-btn-ghost:hover { color: #e2e8f0; }
.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: #64748b; margin-top: 2px; }
.rw-card-source { font-size: 10px; color: #94a3b8; }
.rw-actions { display: flex; gap: 2px; flex-shrink: 0; }
.rw-empty { text-align: center; padding: 20px; color: #64748b; font-size: 13px; }
.rw-loading { text-align: center; padding: 20px; color: #94a3b8; font-size: 13px; }
.rw-form { background: rgba(15,23,42,0.6); 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 #334155; background: rgba(30,41,59,0.8); color: #e2e8f0; font-size: 13px; margin-bottom: 6px; box-sizing: border-box; outline: none; font-family: inherit; }
.rw-input:focus { border-color: #f59e0b; }
.rw-form-actions { display: flex; gap: 6px; }
.widget-drop-active { background: rgba(245,158,11,0.1); border: 2px dashed #f59e0b; border-radius: 8px; }
</style>
`;
if (this.loading) {
this.shadow.innerHTML = `${styles}<div class="rw-loading">Loading reminders...</div>`;
return;
}
const cards = this.reminders.map(r => `
<div class="rw-card">
<div class="rw-dot" style="background:${r.sourceColor || "#f59e0b"}"></div>
<div class="rw-info">
<div class="rw-card-title">${this.esc(r.title)}</div>
<div class="rw-card-meta">${this.formatRelativeTime(r.remindAt)}</div>
${r.sourceLabel ? `<div class="rw-card-source">${this.esc(r.sourceLabel)}</div>` : ""}
</div>
<div class="rw-actions">
<button class="rw-btn-sm rw-btn-ghost" data-complete="${r.id}" title="Complete">&#10003;</button>
<button class="rw-btn-sm rw-btn-ghost" data-snooze="${r.id}" title="Snooze 24h">&#9716;</button>
<button class="rw-btn-sm rw-btn-ghost" data-delete="${r.id}" title="Delete">&#10005;</button>
</div>
</div>
`).join("");
const addForm = this.showAddForm ? `
<div class="rw-form">
<input type="text" class="rw-input" id="rw-title" placeholder="Reminder title..." value="${this.esc(this.formTitle)}">
<input type="date" class="rw-input" id="rw-date" value="${this.formDate}">
<div class="rw-form-actions">
<button class="rw-btn rw-btn-primary" data-action="submit">Add</button>
<button class="rw-btn rw-btn-ghost" data-action="cancel">Cancel</button>
</div>
</div>
` : "";
this.shadow.innerHTML = `
${styles}
<div id="widget-root">
<div class="rw-header">
<span class="rw-title">&#128276; Reminders</span>
<button class="rw-btn rw-btn-primary" data-action="add">+ Add</button>
</div>
${addForm}
${this.reminders.length > 0 ? cards : '<div class="rw-empty">No upcoming reminders</div>'}
</div>
`;
this.attachListeners();
}
private attachListeners() {
// Add button
this.shadow.querySelector("[data-action='add']")?.addEventListener("click", () => {
this.showAddForm = !this.showAddForm;
this.formTitle = "";
this.formDate = new Date().toISOString().slice(0, 10);
this.render();
});
// Form submit
this.shadow.querySelector("[data-action='submit']")?.addEventListener("click", () => {
const title = (this.shadow.getElementById("rw-title") as HTMLInputElement)?.value || "";
const date = (this.shadow.getElementById("rw-date") as HTMLInputElement)?.value || "";
this.createReminder(title, date);
});
// Form cancel
this.shadow.querySelector("[data-action='cancel']")?.addEventListener("click", () => {
this.showAddForm = false;
this.render();
});
// Complete
this.shadow.querySelectorAll<HTMLButtonElement>("[data-complete]").forEach(btn => {
btn.addEventListener("click", () => this.completeReminder(btn.dataset.complete!));
});
// Snooze
this.shadow.querySelectorAll<HTMLButtonElement>("[data-snooze]").forEach(btn => {
btn.addEventListener("click", () => this.snoozeReminder(btn.dataset.snooze!));
});
// Delete
this.shadow.querySelectorAll<HTMLButtonElement>("[data-delete]").forEach(btn => {
btn.addEventListener("click", () => this.deleteReminder(btn.dataset.delete!));
});
// Drop target on the whole widget
const root = this.shadow.getElementById("widget-root");
if (root) {
root.addEventListener("dragover", (e) => {
e.preventDefault();
root.classList.add("widget-drop-active");
});
root.addEventListener("dragleave", () => {
root.classList.remove("widget-drop-active");
});
root.addEventListener("drop", (e) => this.handleDrop(e as DragEvent));
}
}
}
customElements.define("folk-reminders-widget", FolkRemindersWidget);

View File

@ -34,12 +34,31 @@ interface LogEntry {
timestamp: number;
}
interface ReminderData {
id: string;
title: string;
description: string;
remindAt: number;
allDay: boolean;
timezone: string;
notifyEmail: string | null;
notified: boolean;
completed: boolean;
sourceModule: string | null;
sourceLabel: string | null;
sourceColor: string | null;
cronExpression: string | null;
createdAt: number;
updatedAt: number;
}
const ACTION_TYPES = [
{ value: "email", label: "Email" },
{ value: "webhook", label: "Webhook" },
{ value: "calendar-event", label: "Calendar Event" },
{ value: "broadcast", label: "Broadcast" },
{ value: "backlog-briefing", label: "Backlog Briefing" },
{ value: "calendar-reminder", label: "Calendar Reminder" },
];
const CRON_PRESETS = [
@ -58,11 +77,22 @@ class FolkScheduleApp extends HTMLElement {
private space = "";
private jobs: JobData[] = [];
private log: LogEntry[] = [];
private view: "jobs" | "log" | "form" = "jobs";
private reminders: ReminderData[] = [];
private view: "jobs" | "log" | "form" | "reminders" | "reminder-form" = "jobs";
private editingJob: JobData | null = null;
private editingReminder: ReminderData | null = null;
private loading = false;
private runningJobId: string | null = null;
// Reminder form state
private rFormTitle = "";
private rFormDescription = "";
private rFormDate = "";
private rFormTime = "09:00";
private rFormAllDay = true;
private rFormEmail = "";
private rFormSyncCal = true;
// Form state
private formName = "";
private formDescription = "";
@ -115,6 +145,104 @@ class FolkScheduleApp extends HTMLElement {
this.render();
}
private async loadReminders() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/reminders`);
if (res.ok) {
const data = await res.json();
this.reminders = data.results || [];
}
} catch { this.reminders = []; }
this.render();
}
private async completeReminder(id: string) {
const base = this.getApiBase();
await fetch(`${base}/api/reminders/${id}/complete`, { method: "POST" });
await this.loadReminders();
}
private async deleteReminder(id: string) {
if (!confirm("Delete this reminder?")) return;
const base = this.getApiBase();
await fetch(`${base}/api/reminders/${id}`, { method: "DELETE" });
await this.loadReminders();
}
private async snoozeReminder(id: string) {
const base = this.getApiBase();
await fetch(`${base}/api/reminders/${id}/snooze`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hours: 24 }),
});
await this.loadReminders();
}
private async submitReminderForm() {
const base = this.getApiBase();
const remindAt = this.rFormAllDay
? new Date(this.rFormDate + "T09:00:00").getTime()
: new Date(this.rFormDate + "T" + this.rFormTime).getTime();
const payload: Record<string, unknown> = {
title: this.rFormTitle,
description: this.rFormDescription,
remindAt,
allDay: this.rFormAllDay,
notifyEmail: this.rFormEmail || null,
syncToCalendar: this.rFormSyncCal,
};
const isEdit = !!this.editingReminder;
const url = isEdit ? `${base}/api/reminders/${this.editingReminder!.id}` : `${base}/api/reminders`;
const method = isEdit ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Request failed" }));
alert(err.error || "Failed to save reminder");
return;
}
this.view = "reminders";
this.editingReminder = null;
await this.loadReminders();
}
private openCreateReminderForm() {
this.editingReminder = null;
this.rFormTitle = "";
this.rFormDescription = "";
this.rFormDate = new Date().toISOString().slice(0, 10);
this.rFormTime = "09:00";
this.rFormAllDay = true;
this.rFormEmail = "";
this.rFormSyncCal = true;
this.view = "reminder-form";
this.render();
}
private openEditReminderForm(r: ReminderData) {
this.editingReminder = r;
const d = new Date(r.remindAt);
this.rFormTitle = r.title;
this.rFormDescription = r.description;
this.rFormDate = d.toISOString().slice(0, 10);
this.rFormTime = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
this.rFormAllDay = r.allDay;
this.rFormEmail = r.notifyEmail || "";
this.rFormSyncCal = true;
this.view = "reminder-form";
this.render();
}
private async toggleJob(id: string, enabled: boolean) {
const base = this.getApiBase();
await fetch(`${base}/api/jobs/${id}`, {
@ -352,17 +480,27 @@ class FolkScheduleApp extends HTMLElement {
content = this.renderLog();
} else if (this.view === "form") {
content = this.renderForm();
} else if (this.view === "reminders") {
content = this.renderReminderList();
} else if (this.view === "reminder-form") {
content = this.renderReminderForm();
}
const activeTab = this.view === "form" ? "jobs" : this.view === "reminder-form" ? "reminders" : this.view;
let headerAction = "";
if (this.view === "jobs") headerAction = `<button class="s-btn s-btn-primary" data-action="create">+ New Job</button>`;
else if (this.view === "reminders") headerAction = `<button class="s-btn s-btn-primary" data-action="create-reminder">+ New Reminder</button>`;
this.shadow.innerHTML = `
${styles}
<div class="s-header">
<h1 class="s-title">rSchedule</h1>
<div class="s-tabs">
<button class="s-tab ${this.view === "jobs" ? "active" : ""}" data-view="jobs">Jobs</button>
<button class="s-tab ${this.view === "log" ? "active" : ""}" data-view="log">Execution Log</button>
<button class="s-tab ${activeTab === "jobs" ? "active" : ""}" data-view="jobs">Jobs</button>
<button class="s-tab ${activeTab === "reminders" ? "active" : ""}" data-view="reminders">Reminders</button>
<button class="s-tab ${activeTab === "log" ? "active" : ""}" data-view="log">Execution Log</button>
</div>
${this.view === "jobs" ? `<button class="s-btn s-btn-primary" data-action="create">+ New Job</button>` : ""}
${headerAction}
</div>
${content}
`;
@ -491,12 +629,103 @@ class FolkScheduleApp extends HTMLElement {
`;
}
private renderReminderList(): string {
if (this.reminders.length === 0) {
return `<div class="s-empty"><p>No reminders yet.</p><p style="margin-top:8px"><button class="s-btn s-btn-primary" data-action="create-reminder">Create your first reminder</button></p></div>`;
}
const rows = this.reminders.map((r) => {
const dateStr = new Date(r.remindAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
const timeStr = r.allDay ? "All day" : new Date(r.remindAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
const statusBadge = r.completed
? '<span style="background:rgba(34,197,94,0.15);color:#22c55e;padding:2px 8px;border-radius:4px;font-size:12px">Done</span>'
: r.notified
? '<span style="background:rgba(59,130,246,0.15);color:#3b82f6;padding:2px 8px;border-radius:4px;font-size:12px">Sent</span>'
: '<span style="background:rgba(245,158,11,0.15);color:#f59e0b;padding:2px 8px;border-radius:4px;font-size:12px">Pending</span>';
return `<tr>
<td>
<div style="display:flex;align-items:center;gap:8px">
<span style="width:8px;height:8px;border-radius:50%;background:${r.sourceColor || "#f59e0b"};flex-shrink:0"></span>
<div>
<strong style="color:#e2e8f0">${this.esc(r.title)}</strong>
${r.description ? `<br><span style="color:#64748b;font-size:12px">${this.esc(r.description.slice(0, 60))}</span>` : ""}
</div>
</div>
</td>
<td style="color:#94a3b8;font-size:13px">${dateStr}<br><span style="font-size:11px;color:#64748b">${timeStr}</span></td>
<td>${r.sourceLabel ? `<span style="color:${r.sourceColor || "#94a3b8"};font-size:12px">${this.esc(r.sourceLabel)}</span>` : '<span style="color:#64748b;font-size:12px">Free-form</span>'}</td>
<td>${statusBadge}</td>
<td>
<div class="s-actions">
${!r.completed ? `<button class="s-btn s-btn-secondary s-btn-sm" data-r-complete="${r.id}">Complete</button>` : ""}
${!r.completed ? `<button class="s-btn s-btn-secondary s-btn-sm" data-r-snooze="${r.id}">Snooze</button>` : ""}
<button class="s-btn s-btn-secondary s-btn-sm" data-r-edit="${r.id}">Edit</button>
<button class="s-btn s-btn-danger s-btn-sm" data-r-delete="${r.id}">Delete</button>
</div>
</td>
</tr>`;
}).join("");
return `
<div style="overflow-x:auto">
<table class="s-table">
<thead>
<tr>
<th>Reminder</th>
<th>Date</th>
<th>Source</th>
<th>Status</th>
<th style="width:220px">Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
private renderReminderForm(): string {
const isEdit = !!this.editingReminder;
return `
<div class="s-card">
<h2 style="margin:0 0 20px;font-size:1.1rem">${isEdit ? "Edit Reminder" : "Create New Reminder"}</h2>
<div class="s-form-grid">
<label class="s-label s-form-full">Title <input type="text" class="s-input" id="rf-title" value="${this.esc(this.rFormTitle)}" placeholder="Reminder title..."></label>
<label class="s-label s-form-full">Description <input type="text" class="s-input" id="rf-desc" value="${this.esc(this.rFormDescription)}" placeholder="Optional description..."></label>
<label class="s-label">Date <input type="date" class="s-input" id="rf-date" value="${this.rFormDate}"></label>
<label class="s-label">
All Day
<label class="s-toggle" style="margin-top:4px">
<input type="checkbox" id="rf-allday" ${this.rFormAllDay ? "checked" : ""}>
<span class="slider"></span>
</label>
</label>
${!this.rFormAllDay ? `<label class="s-label">Time <input type="time" class="s-input" id="rf-time" value="${this.rFormTime}"></label>` : ""}
<label class="s-label">Email Notification <input type="email" class="s-input" id="rf-email" value="${this.esc(this.rFormEmail)}" placeholder="user@example.com (optional)"></label>
<label class="s-label">
Sync to Calendar
<label class="s-toggle" style="margin-top:4px">
<input type="checkbox" id="rf-sync" ${this.rFormSyncCal ? "checked" : ""}>
<span class="slider"></span>
</label>
</label>
</div>
<div style="display:flex;gap:8px;margin-top:24px">
<button class="s-btn s-btn-primary" data-action="submit-reminder">${isEdit ? "Update Reminder" : "Create Reminder"}</button>
<button class="s-btn s-btn-secondary" data-action="cancel-reminder">Cancel</button>
</div>
</div>
`;
}
private attachListeners() {
// Tab switching
this.shadow.querySelectorAll<HTMLButtonElement>("[data-view]").forEach((btn) => {
btn.addEventListener("click", () => {
this.view = btn.dataset.view as "jobs" | "log";
this.view = btn.dataset.view as "jobs" | "log" | "reminders";
if (this.view === "log") this.loadLog();
else if (this.view === "reminders") this.loadReminders();
else this.render();
});
});
@ -564,6 +793,53 @@ class FolkScheduleApp extends HTMLElement {
});
this.attachConfigListeners();
// Reminder: create button
this.shadow.querySelectorAll<HTMLButtonElement>("[data-action='create-reminder']").forEach((btn) => {
btn.addEventListener("click", () => this.openCreateReminderForm());
});
// Reminder: complete
this.shadow.querySelectorAll<HTMLButtonElement>("[data-r-complete]").forEach((btn) => {
btn.addEventListener("click", () => this.completeReminder(btn.dataset.rComplete!));
});
// Reminder: snooze
this.shadow.querySelectorAll<HTMLButtonElement>("[data-r-snooze]").forEach((btn) => {
btn.addEventListener("click", () => this.snoozeReminder(btn.dataset.rSnooze!));
});
// Reminder: edit
this.shadow.querySelectorAll<HTMLButtonElement>("[data-r-edit]").forEach((btn) => {
btn.addEventListener("click", () => {
const r = this.reminders.find((rem) => rem.id === btn.dataset.rEdit);
if (r) this.openEditReminderForm(r);
});
});
// Reminder: delete
this.shadow.querySelectorAll<HTMLButtonElement>("[data-r-delete]").forEach((btn) => {
btn.addEventListener("click", () => this.deleteReminder(btn.dataset.rDelete!));
});
// Reminder form: cancel
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel-reminder']")?.addEventListener("click", () => {
this.view = "reminders";
this.loadReminders();
});
// Reminder form: submit
this.shadow.querySelector<HTMLButtonElement>("[data-action='submit-reminder']")?.addEventListener("click", () => {
this.collectReminderFormData();
this.submitReminderForm();
});
// Reminder form: all-day toggle re-renders to show/hide time field
this.shadow.querySelector<HTMLInputElement>("#rf-allday")?.addEventListener("change", (e) => {
this.collectReminderFormData();
this.rFormAllDay = (e.target as HTMLInputElement).checked;
this.render();
});
}
private attachConfigListeners() {
@ -597,6 +873,24 @@ class FolkScheduleApp extends HTMLElement {
this.formConfig[el.dataset.config!] = el.value;
});
}
private collectReminderFormData() {
const getTitle = this.shadow.querySelector<HTMLInputElement>("#rf-title");
const getDesc = this.shadow.querySelector<HTMLInputElement>("#rf-desc");
const getDate = this.shadow.querySelector<HTMLInputElement>("#rf-date");
const getTime = this.shadow.querySelector<HTMLInputElement>("#rf-time");
const getAllDay = this.shadow.querySelector<HTMLInputElement>("#rf-allday");
const getEmail = this.shadow.querySelector<HTMLInputElement>("#rf-email");
const getSync = this.shadow.querySelector<HTMLInputElement>("#rf-sync");
if (getTitle) this.rFormTitle = getTitle.value;
if (getDesc) this.rFormDescription = getDesc.value;
if (getDate) this.rFormDate = getDate.value;
if (getTime) this.rFormTime = getTime.value;
if (getAllDay) this.rFormAllDay = getAllDay.checked;
if (getEmail) this.rFormEmail = getEmail.value;
if (getSync) this.rFormSyncCal = getSync.checked;
}
}
customElements.define("folk-schedule-app", FolkScheduleApp);

View File

@ -22,15 +22,17 @@ import {
scheduleSchema,
scheduleDocId,
MAX_LOG_ENTRIES,
MAX_REMINDERS,
} from "./schemas";
import type {
ScheduleDoc,
ScheduleJob,
ExecutionLogEntry,
ActionType,
Reminder,
} from "./schemas";
import { calendarDocId } from "../rcal/schemas";
import type { CalendarDoc } from "../rcal/schemas";
import type { CalendarDoc, ScheduledItemMetadata } from "../rcal/schemas";
let _syncServer: SyncServer | null = null;
@ -70,6 +72,7 @@ function ensureDoc(space: string): ScheduleDoc {
d.meta = init.meta;
d.meta.spaceSlug = space;
d.jobs = {};
d.reminders = {};
d.log = [];
},
);
@ -436,6 +439,104 @@ async function executeBacklogBriefing(
return { success: true, message: `${mode} briefing sent to ${config.to} (${filtered.length} tasks)` };
}
async function executeCalendarReminder(
job: ScheduleJob,
space: string,
): Promise<{ success: boolean; message: string }> {
if (!_syncServer)
return { success: false, message: "SyncServer not available" };
const transport = getSmtpTransport();
if (!transport)
return { success: false, message: "SMTP not configured (SMTP_PASS missing)" };
const config = job.actionConfig as { to?: string };
if (!config.to)
return { success: false, message: "No recipient (to) configured" };
// Load the calendar doc for this space
const calDocId = calendarDocId(space);
const calDoc = _syncServer.getDoc<CalendarDoc>(calDocId);
if (!calDoc)
return { success: false, message: `Calendar doc not found for space ${space}` };
// Find scheduled items due today that haven't been reminded yet
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const todayEnd = todayStart + 86400000;
const dueItems = Object.values(calDoc.events).filter((ev) => {
const meta = ev.metadata as ScheduledItemMetadata | null;
return meta?.isScheduledItem === true
&& !meta.reminderSent
&& ev.startTime >= todayStart
&& ev.startTime < todayEnd;
});
if (dueItems.length === 0)
return { success: true, message: "No scheduled items due today" };
// Render email with all due items
const dateStr = now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" });
const itemRows = dueItems.map((ev) => {
const meta = ev.metadata as ScheduledItemMetadata;
const preview = meta.itemPreview;
const prov = meta.provenance;
const thumbHtml = preview.thumbnailUrl
? `<img src="${preview.thumbnailUrl}" style="max-width:120px;max-height:80px;border-radius:6px;margin-top:8px" alt="thumbnail">`
: "";
const canvasLink = preview.canvasUrl
? `<a href="${preview.canvasUrl}" style="color:#f59e0b;font-size:12px">Open in Canvas</a>`
: "";
return `<tr>
<td style="padding:12px;border-bottom:1px solid #334155;vertical-align:top">
<div style="font-weight:600;color:#e2e8f0;font-size:14px;margin-bottom:4px">${ev.title}</div>
<div style="color:#94a3b8;font-size:13px;margin-bottom:6px">${preview.textPreview}</div>
<div style="font-size:11px;color:#64748b">
Source: <span style="color:#818cf8">${prov.sourceType}</span> in <span style="color:#818cf8">${prov.sourceSpace}</span>
${prov.rid ? ` &bull; RID: ${prov.rid}` : ""}
</div>
${thumbHtml}
<div style="margin-top:6px">${canvasLink}</div>
</td>
</tr>`;
}).join("\n");
const html = `
<div style="font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;padding:32px;border-radius:12px;max-width:640px;margin:0 auto">
<h1 style="font-size:20px;color:#f59e0b;margin:0 0 8px">Scheduled Knowledge Reminders</h1>
<p style="color:#94a3b8;font-size:13px;margin:0 0 24px">${dateStr} &bull; ${dueItems.length} item${dueItems.length !== 1 ? "s" : ""}</p>
<table style="width:100%;border-collapse:collapse;font-size:14px">
<tbody>${itemRows}</tbody>
</table>
<p style="color:#475569;font-size:11px;margin:24px 0 0;text-align:center">
Sent by rSchedule &bull; <a href="https://rspace.online/${space}/rcal" style="color:#f59e0b">View Calendar</a>
</p>
</div>
`;
await transport.sendMail({
from: process.env.SMTP_FROM || "rSchedule <noreply@rmail.online>",
to: config.to,
subject: `[rSpace] ${dueItems.length} scheduled item${dueItems.length !== 1 ? "s" : ""} for ${dateStr}`,
html,
});
// Mark all sent items as reminded
_syncServer.changeDoc<CalendarDoc>(calDocId, `mark ${dueItems.length} reminders sent`, (d) => {
for (const item of dueItems) {
const ev = d.events[item.id];
if (!ev) continue;
const meta = ev.metadata as ScheduledItemMetadata;
meta.reminderSent = true;
meta.reminderSentAt = Date.now();
ev.updatedAt = Date.now();
}
});
return { success: true, message: `Calendar reminder sent to ${config.to} (${dueItems.length} items)` };
}
// ── Unified executor ──
async function executeJob(
@ -453,6 +554,8 @@ async function executeJob(
return executeBroadcast(job);
case "backlog-briefing":
return executeBacklogBriefing(job);
case "calendar-reminder":
return executeCalendarReminder(job, space);
default:
return { success: false, message: `Unknown action type: ${job.actionType}` };
}
@ -533,6 +636,37 @@ function startTickLoop() {
}
});
}
// ── Process due reminders ──
const dueReminders = Object.values(doc.reminders || {}).filter(
(r) => !r.notified && !r.completed && r.remindAt <= now && r.notifyEmail,
);
for (const reminder of dueReminders) {
try {
const result = await executeReminderEmail(reminder, space);
console.log(
`[Schedule] Reminder ${result.success ? "OK" : "ERR"} "${reminder.title}": ${result.message}`,
);
_syncServer.changeDoc<ScheduleDoc>(docId, `notify reminder ${reminder.id}`, (d) => {
const r = d.reminders[reminder.id];
if (!r) return;
r.notified = true;
r.updatedAt = Date.now();
// Handle recurring reminders
if (r.cronExpression) {
const nextRun = computeNextRun(r.cronExpression, r.timezone);
if (nextRun) {
r.remindAt = nextRun;
r.notified = false;
}
}
});
} catch (e) {
console.error(`[Schedule] Reminder email error for "${reminder.title}":`, e);
}
}
} catch (e) {
console.error(`[Schedule] Tick error for space ${space}:`, e);
}
@ -579,6 +713,17 @@ const SEED_JOBS: Omit<ScheduleJob, "lastRunAt" | "lastRunStatus" | "lastRunMessa
actionConfig: { mode: "monthly", to: "jeff@jeffemmett.com" },
createdBy: "system",
},
{
id: "calendar-reminder-daily",
name: "Daily Calendar Reminders",
description: "Sends email reminders for knowledge items scheduled on today's date.",
enabled: true,
cronExpression: "0 14 * * *",
timezone: "America/Vancouver",
actionType: "calendar-reminder",
actionConfig: { to: "jeff@jeffemmett.com" },
createdBy: "system",
},
];
function seedDefaultJobs(space: string) {
@ -816,6 +961,347 @@ routes.get("/api/log/:jobId", (c) => {
return c.json({ count: log.length, results: log });
});
// ── Reminder helpers ──
function ensureRemindersCalendarSource(space: string): string {
const calDocId = calendarDocId(space);
const calDoc = _syncServer!.getDoc<CalendarDoc>(calDocId);
if (!calDoc) return "";
// Check if "Reminders" source already exists
const existing = Object.values(calDoc.sources).find(
(s) => s.name === "Reminders" && s.sourceType === "rSchedule",
);
if (existing) return existing.id;
const sourceId = crypto.randomUUID();
const now = Date.now();
_syncServer!.changeDoc<CalendarDoc>(calDocId, "create Reminders calendar source", (d) => {
d.sources[sourceId] = {
id: sourceId,
name: "Reminders",
sourceType: "rSchedule",
url: null,
color: "#f59e0b",
isActive: true,
isVisible: true,
syncIntervalMinutes: null,
lastSyncedAt: now,
ownerId: null,
createdAt: now,
};
});
return sourceId;
}
function syncReminderToCalendar(reminder: Reminder, space: string): string | null {
if (!_syncServer) return null;
const calDocId = calendarDocId(space);
const calDoc = _syncServer.getDoc<CalendarDoc>(calDocId);
if (!calDoc) return null;
const sourceId = ensureRemindersCalendarSource(space);
const eventId = crypto.randomUUID();
const now = Date.now();
const duration = reminder.allDay ? 86400000 : 3600000;
_syncServer.changeDoc<CalendarDoc>(calDocId, `sync reminder ${reminder.id} to calendar`, (d) => {
d.events[eventId] = {
id: eventId,
title: reminder.title,
description: reminder.description,
startTime: reminder.remindAt,
endTime: reminder.remindAt + duration,
allDay: reminder.allDay,
timezone: reminder.timezone || "UTC",
rrule: null,
status: null,
visibility: null,
sourceId,
sourceName: "Reminders",
sourceType: "rSchedule",
sourceColor: reminder.sourceColor || "#f59e0b",
locationId: null,
locationName: null,
coordinates: null,
locationGranularity: null,
locationLat: null,
locationLng: null,
isVirtual: false,
virtualUrl: null,
virtualPlatform: null,
rToolSource: "rSchedule",
rToolEntityId: reminder.id,
attendees: [],
attendeeCount: 0,
metadata: null,
createdAt: now,
updatedAt: now,
};
});
return eventId;
}
function deleteCalendarEvent(space: string, eventId: string) {
if (!_syncServer) return;
const calDocId = calendarDocId(space);
const calDoc = _syncServer.getDoc<CalendarDoc>(calDocId);
if (!calDoc || !calDoc.events[eventId]) return;
_syncServer.changeDoc<CalendarDoc>(calDocId, `delete reminder calendar event ${eventId}`, (d) => {
delete d.events[eventId];
});
}
// ── Reminder email executor ──
async function executeReminderEmail(
reminder: Reminder,
space: string,
): Promise<{ success: boolean; message: string }> {
const transport = getSmtpTransport();
if (!transport)
return { success: false, message: "SMTP not configured (SMTP_PASS missing)" };
if (!reminder.notifyEmail)
return { success: false, message: "No email address on reminder" };
const dateStr = new Date(reminder.remindAt).toLocaleDateString("en-US", {
weekday: "long", year: "numeric", month: "long", day: "numeric",
hour: "numeric", minute: "2-digit",
});
const sourceInfo = reminder.sourceModule
? `<p style="color:#94a3b8;font-size:13px;margin:12px 0 0">Source: <span style="color:${reminder.sourceColor || "#f59e0b"}">${reminder.sourceLabel || reminder.sourceModule}</span></p>`
: "";
const html = `
<div style="font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;padding:32px;border-radius:12px;max-width:640px;margin:0 auto">
<h1 style="font-size:20px;color:#f59e0b;margin:0 0 8px">&#128276; Reminder: ${reminder.title}</h1>
<p style="color:#94a3b8;font-size:13px;margin:0 0 16px">${dateStr}</p>
${reminder.description ? `<p style="color:#cbd5e1;font-size:14px;line-height:1.6;margin:0 0 16px">${reminder.description}</p>` : ""}
${sourceInfo}
<p style="color:#475569;font-size:11px;margin:24px 0 0;text-align:center">
Sent by rSchedule &bull; <a href="https://rspace.online/${space}/rschedule" style="color:#f59e0b">Manage Reminders</a>
</p>
</div>
`;
await transport.sendMail({
from: process.env.SMTP_FROM || "rSchedule <noreply@rmail.online>",
to: reminder.notifyEmail,
subject: `[Reminder] ${reminder.title}`,
html,
});
return { success: true, message: `Reminder email sent to ${reminder.notifyEmail}` };
}
// ── Reminder API routes ──
// GET /api/reminders — list reminders
routes.get("/api/reminders", (c) => {
const space = c.req.param("space") || "demo";
const doc = ensureDoc(space);
let reminders = Object.values(doc.reminders);
// Query filters
const upcoming = c.req.query("upcoming");
const completed = c.req.query("completed");
if (completed === "false") {
reminders = reminders.filter((r) => !r.completed);
} else if (completed === "true") {
reminders = reminders.filter((r) => r.completed);
}
if (upcoming) {
const days = parseInt(upcoming) || 7;
const now = Date.now();
const cutoff = now + days * 86400000;
reminders = reminders.filter((r) => r.remindAt >= now && r.remindAt <= cutoff);
}
reminders.sort((a, b) => a.remindAt - b.remindAt);
return c.json({ count: reminders.length, results: reminders });
});
// POST /api/reminders — create a reminder
routes.post("/api/reminders", async (c) => {
const space = c.req.param("space") || "demo";
const body = await c.req.json();
const { title, description, remindAt, allDay, timezone, notifyEmail, syncToCalendar, cronExpression } = body;
if (!title?.trim() || !remindAt)
return c.json({ error: "title and remindAt required" }, 400);
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
if (Object.keys(doc.reminders).length >= MAX_REMINDERS)
return c.json({ error: `Maximum ${MAX_REMINDERS} reminders reached` }, 400);
const reminderId = crypto.randomUUID();
const now = Date.now();
const reminder: Reminder = {
id: reminderId,
title: title.trim(),
description: description || "",
remindAt: typeof remindAt === "number" ? remindAt : new Date(remindAt).getTime(),
allDay: allDay || false,
timezone: timezone || "UTC",
notifyEmail: notifyEmail || null,
notified: false,
completed: false,
sourceModule: body.sourceModule || null,
sourceEntityId: body.sourceEntityId || null,
sourceLabel: body.sourceLabel || null,
sourceColor: body.sourceColor || null,
cronExpression: cronExpression || null,
calendarEventId: null,
createdBy: "user",
createdAt: now,
updatedAt: now,
};
// Sync to calendar if requested
if (syncToCalendar) {
const eventId = syncReminderToCalendar(reminder, space);
if (eventId) reminder.calendarEventId = eventId;
}
_syncServer!.changeDoc<ScheduleDoc>(docId, `create reminder ${reminderId}`, (d) => {
d.reminders[reminderId] = reminder;
});
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
return c.json(updated.reminders[reminderId], 201);
});
// GET /api/reminders/:id — get single reminder
routes.get("/api/reminders/:id", (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
const doc = ensureDoc(space);
const reminder = doc.reminders[id];
if (!reminder) return c.json({ error: "Reminder not found" }, 404);
return c.json(reminder);
});
// PUT /api/reminders/:id — update a reminder
routes.put("/api/reminders/:id", async (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
const body = await c.req.json();
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
_syncServer!.changeDoc<ScheduleDoc>(docId, `update reminder ${id}`, (d) => {
const r = d.reminders[id];
if (!r) return;
if (body.title !== undefined) r.title = body.title;
if (body.description !== undefined) r.description = body.description;
if (body.remindAt !== undefined) r.remindAt = typeof body.remindAt === "number" ? body.remindAt : new Date(body.remindAt).getTime();
if (body.allDay !== undefined) r.allDay = body.allDay;
if (body.timezone !== undefined) r.timezone = body.timezone;
if (body.notifyEmail !== undefined) r.notifyEmail = body.notifyEmail;
if (body.cronExpression !== undefined) r.cronExpression = body.cronExpression;
r.updatedAt = Date.now();
});
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
return c.json(updated.reminders[id]);
});
// DELETE /api/reminders/:id — delete (cascades to calendar)
routes.delete("/api/reminders/:id", (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
const reminder = doc.reminders[id];
if (!reminder) return c.json({ error: "Reminder not found" }, 404);
// Cascade: delete linked calendar event
if (reminder.calendarEventId) {
deleteCalendarEvent(space, reminder.calendarEventId);
}
_syncServer!.changeDoc<ScheduleDoc>(docId, `delete reminder ${id}`, (d) => {
delete d.reminders[id];
});
return c.json({ ok: true });
});
// POST /api/reminders/:id/complete — mark completed
routes.post("/api/reminders/:id/complete", (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
_syncServer!.changeDoc<ScheduleDoc>(docId, `complete reminder ${id}`, (d) => {
const r = d.reminders[id];
if (!r) return;
r.completed = true;
r.updatedAt = Date.now();
});
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
return c.json(updated.reminders[id]);
});
// POST /api/reminders/:id/snooze — reschedule to a new date
routes.post("/api/reminders/:id/snooze", async (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
const body = await c.req.json();
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
const newRemindAt = body.remindAt
? (typeof body.remindAt === "number" ? body.remindAt : new Date(body.remindAt).getTime())
: Date.now() + (body.hours || 24) * 3600000;
_syncServer!.changeDoc<ScheduleDoc>(docId, `snooze reminder ${id}`, (d) => {
const r = d.reminders[id];
if (!r) return;
r.remindAt = newRemindAt;
r.notified = false;
r.updatedAt = Date.now();
});
// Update linked calendar event if exists
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
const reminder = updated.reminders[id];
if (reminder?.calendarEventId) {
const calDocId = calendarDocId(space);
const duration = reminder.allDay ? 86400000 : 3600000;
_syncServer!.changeDoc<CalendarDoc>(calDocId, `update reminder event time`, (d) => {
const ev = d.events[reminder.calendarEventId!];
if (ev) {
ev.startTime = newRemindAt;
ev.endTime = newRemindAt + duration;
ev.updatedAt = Date.now();
}
});
}
return c.json(updated.reminders[id]);
});
// ── Module export ──
export const scheduleModule: RSpaceModule = {
@ -850,6 +1336,7 @@ export const scheduleModule: RSpaceModule = {
acceptsFeeds: ["data", "governance"],
outputPaths: [
{ path: "jobs", name: "Jobs", icon: "⏱", description: "Scheduled jobs and their configurations" },
{ path: "reminders", name: "Reminders", icon: "🔔", description: "Scheduled reminders with email notifications" },
{ path: "log", name: "Execution Log", icon: "📋", description: "History of job executions" },
],
};

View File

@ -9,7 +9,7 @@ import type { DocSchema } from '../../shared/local-first/document';
// ── Document types ──
export type ActionType = 'email' | 'webhook' | 'calendar-event' | 'broadcast' | 'backlog-briefing';
export type ActionType = 'email' | 'webhook' | 'calendar-event' | 'broadcast' | 'backlog-briefing' | 'calendar-reminder';
export interface ScheduleJob {
id: string;
@ -47,6 +47,34 @@ export interface ExecutionLogEntry {
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; // "rwork", "rnotes", etc.
sourceEntityId: string | null;
sourceLabel: string | null; // "rWork Task"
sourceColor: string | null; // "#f97316"
// Optional recurrence
cronExpression: string | null;
// Link to rCal event (bidirectional)
calendarEventId: string | null;
createdBy: string;
createdAt: number;
updatedAt: number;
}
export interface ScheduleDoc {
meta: {
module: string;
@ -56,6 +84,7 @@ export interface ScheduleDoc {
createdAt: number;
};
jobs: Record<string, ScheduleJob>;
reminders: Record<string, Reminder>;
log: ExecutionLogEntry[];
}
@ -74,6 +103,7 @@ export const scheduleSchema: DocSchema<ScheduleDoc> = {
createdAt: Date.now(),
},
jobs: {},
reminders: {},
log: [],
}),
};
@ -86,3 +116,6 @@ export function scheduleDocId(space: string) {
/** Maximum execution log entries to keep per doc */
export const MAX_LOG_ENTRIES = 200;
/** Maximum reminders per space */
export const MAX_REMINDERS = 500;

View File

@ -431,7 +431,18 @@ class FolkWorkBoard extends HTMLElement {
const el = card as HTMLElement;
this.dragTaskId = el.dataset.taskId || null;
el.classList.add("dragging");
(e as DragEvent).dataTransfer?.setData("text/plain", this.dragTaskId || "");
const dt = (e as DragEvent).dataTransfer;
if (dt && this.dragTaskId) {
const task = this.tasks.find(t => t.id === this.dragTaskId);
dt.setData("text/plain", task?.title || this.dragTaskId);
dt.setData("application/rspace-item", JSON.stringify({
module: "rwork",
entityId: this.dragTaskId,
title: task?.title || "",
label: "rWork Task",
color: "#f97316",
}));
}
});
card.addEventListener("dragend", () => {
(card as HTMLElement).classList.remove("dragging");

View File

@ -531,6 +531,26 @@ export default defineConfig({
},
});
// Build network CRM view component
await build({
configFile: false,
root: resolve(__dirname, "modules/rnetwork/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rnetwork"),
lib: {
entry: resolve(__dirname, "modules/rnetwork/components/folk-crm-view.ts"),
formats: ["es"],
fileName: () => "folk-crm-view.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-crm-view.js",
},
},
},
});
// Copy network CSS
mkdirSync(resolve(__dirname, "dist/modules/rnetwork"), { recursive: true });
copyFileSync(
@ -734,6 +754,26 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rschedule/schedule.css"),
);
// Build schedule reminders widget component
await build({
configFile: false,
root: resolve(__dirname, "modules/rschedule/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rschedule"),
lib: {
entry: resolve(__dirname, "modules/rschedule/components/folk-reminders-widget.ts"),
formats: ["es"],
fileName: () => "folk-reminders-widget.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-reminders-widget.js",
},
},
},
});
// ── Demo infrastructure ──
// Build demo-sync-vanilla library