From bb052c49d3cd7ded46c1b4a41a46abd43953baf7 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 17:37:54 -0800 Subject: [PATCH] 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 --- modules/rcal/components/folk-calendar-view.ts | 527 +++++++++++++++++- .../components/folk-reminders-widget.ts | 283 ++++++++++ .../rschedule/components/folk-schedule-app.ts | 304 +++++++++- modules/rschedule/mod.ts | 489 +++++++++++++++- modules/rschedule/schemas.ts | 35 +- modules/rwork/components/folk-work-board.ts | 13 +- vite.config.ts | 40 ++ 7 files changed, 1670 insertions(+), 21 deletions(-) create mode 100644 modules/rschedule/components/folk-reminders-widget.ts diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index e94619b..139c14c 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -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 = { - 2: "day", 3: "week", 4: "month", 5: "season", 6: "year" +const T_TO_VIEW: Record = { + 2: "day", 3: "week", 4: "month", 5: "season", 6: "year", 7: "multi-year" +}; + +const VIEW_VARIANTS: Record = { + 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 = {}; @@ -124,6 +128,12 @@ class FolkCalendarView extends HTMLElement { private lunarOverlayExpanded = false; private _wheelTimer: ReturnType | 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 = { + '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 { +/- zoom • \u2190/\u2192 nav • t today • - 1-5 view • + 1-6 view • + v variant • m map • c coupling • l lunar • @@ -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 `
@@ -589,6 +681,11 @@ class FolkCalendarView extends HTMLElement {
`).join("")} + ${maxVariant > 1 ? `
+ ${Array.from({length: maxVariant}, (_, i) => + `` + ).join("")} +
` : ""} + + + + + `).join(""); + + const addForm = this.showAddForm ? ` +
+ + +
+ + +
+
+ ` : ""; + + this.shadow.innerHTML = ` + ${styles} +
+
+ 🔔 Reminders + +
+ ${addForm} + ${this.reminders.length > 0 ? cards : '
No upcoming reminders
'} +
+ `; + + this.attachListeners(); + } + + private attachListeners() { + // Add button + this.shadow.querySelector("[data-action='add']")?.addEventListener("click", () => { + this.showAddForm = !this.showAddForm; + this.formTitle = ""; + this.formDate = new Date().toISOString().slice(0, 10); + this.render(); + }); + + // Form submit + this.shadow.querySelector("[data-action='submit']")?.addEventListener("click", () => { + const title = (this.shadow.getElementById("rw-title") as HTMLInputElement)?.value || ""; + const date = (this.shadow.getElementById("rw-date") as HTMLInputElement)?.value || ""; + this.createReminder(title, date); + }); + + // Form cancel + this.shadow.querySelector("[data-action='cancel']")?.addEventListener("click", () => { + this.showAddForm = false; + this.render(); + }); + + // Complete + this.shadow.querySelectorAll("[data-complete]").forEach(btn => { + btn.addEventListener("click", () => this.completeReminder(btn.dataset.complete!)); + }); + + // Snooze + this.shadow.querySelectorAll("[data-snooze]").forEach(btn => { + btn.addEventListener("click", () => this.snoozeReminder(btn.dataset.snooze!)); + }); + + // Delete + this.shadow.querySelectorAll("[data-delete]").forEach(btn => { + btn.addEventListener("click", () => this.deleteReminder(btn.dataset.delete!)); + }); + + // Drop target on the whole widget + const root = this.shadow.getElementById("widget-root"); + if (root) { + root.addEventListener("dragover", (e) => { + e.preventDefault(); + root.classList.add("widget-drop-active"); + }); + root.addEventListener("dragleave", () => { + root.classList.remove("widget-drop-active"); + }); + root.addEventListener("drop", (e) => this.handleDrop(e as DragEvent)); + } + } +} + +customElements.define("folk-reminders-widget", FolkRemindersWidget); diff --git a/modules/rschedule/components/folk-schedule-app.ts b/modules/rschedule/components/folk-schedule-app.ts index 24c0c66..3fc323d 100644 --- a/modules/rschedule/components/folk-schedule-app.ts +++ b/modules/rschedule/components/folk-schedule-app.ts @@ -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 = { + 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 = ``; + else if (this.view === "reminders") headerAction = ``; + this.shadow.innerHTML = ` ${styles}

rSchedule

- - + + +
- ${this.view === "jobs" ? `` : ""} + ${headerAction}
${content} `; @@ -491,12 +629,103 @@ class FolkScheduleApp extends HTMLElement { `; } + private renderReminderList(): string { + if (this.reminders.length === 0) { + return `

No reminders yet.

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

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

+
+ + + + + ${!this.rFormAllDay ? `` : ""} + + +
+
+ + +
+
+ `; + } + private attachListeners() { // Tab switching this.shadow.querySelectorAll("[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("[data-action='create-reminder']").forEach((btn) => { + btn.addEventListener("click", () => this.openCreateReminderForm()); + }); + + // Reminder: complete + this.shadow.querySelectorAll("[data-r-complete]").forEach((btn) => { + btn.addEventListener("click", () => this.completeReminder(btn.dataset.rComplete!)); + }); + + // Reminder: snooze + this.shadow.querySelectorAll("[data-r-snooze]").forEach((btn) => { + btn.addEventListener("click", () => this.snoozeReminder(btn.dataset.rSnooze!)); + }); + + // Reminder: edit + this.shadow.querySelectorAll("[data-r-edit]").forEach((btn) => { + btn.addEventListener("click", () => { + const r = this.reminders.find((rem) => rem.id === btn.dataset.rEdit); + if (r) this.openEditReminderForm(r); + }); + }); + + // Reminder: delete + this.shadow.querySelectorAll("[data-r-delete]").forEach((btn) => { + btn.addEventListener("click", () => this.deleteReminder(btn.dataset.rDelete!)); + }); + + // Reminder form: cancel + this.shadow.querySelector("[data-action='cancel-reminder']")?.addEventListener("click", () => { + this.view = "reminders"; + this.loadReminders(); + }); + + // Reminder form: submit + this.shadow.querySelector("[data-action='submit-reminder']")?.addEventListener("click", () => { + this.collectReminderFormData(); + this.submitReminderForm(); + }); + + // Reminder form: all-day toggle re-renders to show/hide time field + this.shadow.querySelector("#rf-allday")?.addEventListener("change", (e) => { + this.collectReminderFormData(); + this.rFormAllDay = (e.target as HTMLInputElement).checked; + this.render(); + }); } private attachConfigListeners() { @@ -597,6 +873,24 @@ class FolkScheduleApp extends HTMLElement { this.formConfig[el.dataset.config!] = el.value; }); } + + private collectReminderFormData() { + const getTitle = this.shadow.querySelector("#rf-title"); + const getDesc = this.shadow.querySelector("#rf-desc"); + const getDate = this.shadow.querySelector("#rf-date"); + const getTime = this.shadow.querySelector("#rf-time"); + const getAllDay = this.shadow.querySelector("#rf-allday"); + const getEmail = this.shadow.querySelector("#rf-email"); + const getSync = this.shadow.querySelector("#rf-sync"); + + if (getTitle) this.rFormTitle = getTitle.value; + if (getDesc) this.rFormDescription = getDesc.value; + if (getDate) this.rFormDate = getDate.value; + if (getTime) this.rFormTime = getTime.value; + if (getAllDay) this.rFormAllDay = getAllDay.checked; + if (getEmail) this.rFormEmail = getEmail.value; + if (getSync) this.rFormSyncCal = getSync.checked; + } } customElements.define("folk-schedule-app", FolkScheduleApp); diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts index 4040b87..2c56a65 100644 --- a/modules/rschedule/mod.ts +++ b/modules/rschedule/mod.ts @@ -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(calDocId); + if (!calDoc) + return { success: false, message: `Calendar doc not found for space ${space}` }; + + // Find scheduled items due today that haven't been reminded yet + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const todayEnd = todayStart + 86400000; + + const dueItems = Object.values(calDoc.events).filter((ev) => { + const meta = ev.metadata as ScheduledItemMetadata | null; + return meta?.isScheduledItem === true + && !meta.reminderSent + && ev.startTime >= todayStart + && ev.startTime < todayEnd; + }); + + if (dueItems.length === 0) + return { success: true, message: "No scheduled items due today" }; + + // Render email with all due items + const dateStr = now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }); + const itemRows = dueItems.map((ev) => { + const meta = ev.metadata as ScheduledItemMetadata; + const preview = meta.itemPreview; + const prov = meta.provenance; + const thumbHtml = preview.thumbnailUrl + ? `thumbnail` + : ""; + const canvasLink = preview.canvasUrl + ? `Open in Canvas` + : ""; + return ` + +
${ev.title}
+
${preview.textPreview}
+
+ Source: ${prov.sourceType} in ${prov.sourceSpace} + ${prov.rid ? ` • RID: ${prov.rid}` : ""} +
+ ${thumbHtml} +
${canvasLink}
+ + `; + }).join("\n"); + + const html = ` +
+

Scheduled Knowledge Reminders

+

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

+ + ${itemRows} +
+

+ Sent by rSchedule • View Calendar +

+
+ `; + + await transport.sendMail({ + from: process.env.SMTP_FROM || "rSchedule ", + to: config.to, + subject: `[rSpace] ${dueItems.length} scheduled item${dueItems.length !== 1 ? "s" : ""} for ${dateStr}`, + html, + }); + + // Mark all sent items as reminded + _syncServer.changeDoc(calDocId, `mark ${dueItems.length} reminders sent`, (d) => { + for (const item of dueItems) { + const ev = d.events[item.id]; + if (!ev) continue; + const meta = ev.metadata as ScheduledItemMetadata; + meta.reminderSent = true; + meta.reminderSentAt = Date.now(); + ev.updatedAt = Date.now(); + } + }); + + return { success: true, message: `Calendar reminder sent to ${config.to} (${dueItems.length} items)` }; +} + // ── Unified executor ── async function executeJob( @@ -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(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 { return c.json({ count: log.length, results: log }); }); +// ── Reminder helpers ── + +function ensureRemindersCalendarSource(space: string): string { + const calDocId = calendarDocId(space); + const calDoc = _syncServer!.getDoc(calDocId); + if (!calDoc) return ""; + + // Check if "Reminders" source already exists + const existing = Object.values(calDoc.sources).find( + (s) => s.name === "Reminders" && s.sourceType === "rSchedule", + ); + if (existing) return existing.id; + + const sourceId = crypto.randomUUID(); + const now = Date.now(); + _syncServer!.changeDoc(calDocId, "create Reminders calendar source", (d) => { + d.sources[sourceId] = { + id: sourceId, + name: "Reminders", + sourceType: "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(calDocId); + if (!calDoc) return null; + + const sourceId = ensureRemindersCalendarSource(space); + const eventId = crypto.randomUUID(); + const now = Date.now(); + const duration = reminder.allDay ? 86400000 : 3600000; + + _syncServer.changeDoc(calDocId, `sync reminder ${reminder.id} to calendar`, (d) => { + d.events[eventId] = { + id: eventId, + title: reminder.title, + description: reminder.description, + startTime: reminder.remindAt, + endTime: reminder.remindAt + duration, + allDay: reminder.allDay, + timezone: reminder.timezone || "UTC", + rrule: null, + status: null, + visibility: null, + sourceId, + sourceName: "Reminders", + sourceType: "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(calDocId); + if (!calDoc || !calDoc.events[eventId]) return; + + _syncServer.changeDoc(calDocId, `delete reminder calendar event ${eventId}`, (d) => { + delete d.events[eventId]; + }); +} + +// ── Reminder email executor ── + +async function executeReminderEmail( + reminder: Reminder, + space: string, +): Promise<{ success: boolean; message: string }> { + const transport = getSmtpTransport(); + if (!transport) + return { success: false, message: "SMTP not configured (SMTP_PASS missing)" }; + if (!reminder.notifyEmail) + return { success: false, message: "No email address on reminder" }; + + const dateStr = new Date(reminder.remindAt).toLocaleDateString("en-US", { + weekday: "long", year: "numeric", month: "long", day: "numeric", + hour: "numeric", minute: "2-digit", + }); + + const sourceInfo = reminder.sourceModule + ? `

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

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

🔔 Reminder: ${reminder.title}

+

${dateStr}

+ ${reminder.description ? `

${reminder.description}

` : ""} + ${sourceInfo} +

+ Sent by rSchedule • Manage Reminders +

+
+ `; + + await transport.sendMail({ + from: process.env.SMTP_FROM || "rSchedule ", + 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(docId, `create reminder ${reminderId}`, (d) => { + d.reminders[reminderId] = reminder; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.reminders[reminderId], 201); +}); + +// GET /api/reminders/:id — get single reminder +routes.get("/api/reminders/:id", (c) => { + const space = c.req.param("space") || "demo"; + const 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(docId, `update reminder ${id}`, (d) => { + const r = d.reminders[id]; + if (!r) return; + if (body.title !== undefined) r.title = body.title; + if (body.description !== undefined) r.description = body.description; + if (body.remindAt !== undefined) r.remindAt = typeof body.remindAt === "number" ? body.remindAt : new Date(body.remindAt).getTime(); + if (body.allDay !== undefined) r.allDay = body.allDay; + if (body.timezone !== undefined) r.timezone = body.timezone; + if (body.notifyEmail !== undefined) r.notifyEmail = body.notifyEmail; + if (body.cronExpression !== undefined) r.cronExpression = body.cronExpression; + r.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.reminders[id]); +}); + +// DELETE /api/reminders/:id — delete (cascades to calendar) +routes.delete("/api/reminders/:id", (c) => { + const space = c.req.param("space") || "demo"; + const 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(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(docId, `complete reminder ${id}`, (d) => { + const r = d.reminders[id]; + if (!r) return; + r.completed = true; + r.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.reminders[id]); +}); + +// POST /api/reminders/:id/snooze — reschedule to a new date +routes.post("/api/reminders/:id/snooze", async (c) => { + const space = c.req.param("space") || "demo"; + const 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(docId, `snooze reminder ${id}`, (d) => { + const r = d.reminders[id]; + if (!r) return; + r.remindAt = newRemindAt; + r.notified = false; + r.updatedAt = Date.now(); + }); + + // Update linked calendar event if exists + const updated = _syncServer!.getDoc(docId)!; + const reminder = updated.reminders[id]; + if (reminder?.calendarEventId) { + const calDocId = calendarDocId(space); + const duration = reminder.allDay ? 86400000 : 3600000; + _syncServer!.changeDoc(calDocId, `update reminder event time`, (d) => { + const ev = d.events[reminder.calendarEventId!]; + if (ev) { + ev.startTime = newRemindAt; + ev.endTime = newRemindAt + duration; + ev.updatedAt = Date.now(); + } + }); + } + + return c.json(updated.reminders[id]); +}); + // ── 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" }, ], }; diff --git a/modules/rschedule/schemas.ts b/modules/rschedule/schemas.ts index a3557f7..627b4c8 100644 --- a/modules/rschedule/schemas.ts +++ b/modules/rschedule/schemas.ts @@ -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; + reminders: Record; log: ExecutionLogEntry[]; } @@ -74,6 +103,7 @@ export const scheduleSchema: DocSchema = { 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; diff --git a/modules/rwork/components/folk-work-board.ts b/modules/rwork/components/folk-work-board.ts index 5af32d1..b94cffb 100644 --- a/modules/rwork/components/folk-work-board.ts +++ b/modules/rwork/components/folk-work-board.ts @@ -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"); diff --git a/vite.config.ts b/vite.config.ts index 3f187fe..2b5f65d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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