/** * — 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, "&").replace(/"/g, """).replace(//g, ">"); } private render() { const styles = ` `; if (this.loading) { this.shadow.innerHTML = `${styles}
Loading reminders...
`; return; } const cards = this.reminders.map(r => `
${this.esc(r.title)}
${this.formatRelativeTime(r.remindAt)}
${r.sourceLabel ? `
${this.esc(r.sourceLabel)}
` : ""}
`).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);