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:
parent
da2b21cd98
commit
bb052c49d3
|
|
@ -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 •
|
||||
<kbd>\u2190</kbd>/<kbd>\u2192</kbd> nav •
|
||||
<kbd>t</kbd> today •
|
||||
<kbd>1-5</kbd> view •
|
||||
<kbd>1-6</kbd> view •
|
||||
<kbd>v</kbd> variant •
|
||||
<kbd>m</kbd> map •
|
||||
<kbd>c</kbd> coupling •
|
||||
<kbd>l</kbd> 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 `<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)">−</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">🔔</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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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">✓</button>
|
||||
<button class="rw-btn-sm rw-btn-ghost" data-snooze="${r.id}" title="Snooze 24h">◴</button>
|
||||
<button class="rw-btn-sm rw-btn-ghost" data-delete="${r.id}" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
const addForm = this.showAddForm ? `
|
||||
<div class="rw-form">
|
||||
<input type="text" class="rw-input" id="rw-title" placeholder="Reminder title..." value="${this.esc(this.formTitle)}">
|
||||
<input type="date" class="rw-input" id="rw-date" value="${this.formDate}">
|
||||
<div class="rw-form-actions">
|
||||
<button class="rw-btn rw-btn-primary" data-action="submit">Add</button>
|
||||
<button class="rw-btn rw-btn-ghost" data-action="cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
` : "";
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
${styles}
|
||||
<div id="widget-root">
|
||||
<div class="rw-header">
|
||||
<span class="rw-title">🔔 Reminders</span>
|
||||
<button class="rw-btn rw-btn-primary" data-action="add">+ Add</button>
|
||||
</div>
|
||||
${addForm}
|
||||
${this.reminders.length > 0 ? cards : '<div class="rw-empty">No upcoming reminders</div>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
private attachListeners() {
|
||||
// Add button
|
||||
this.shadow.querySelector("[data-action='add']")?.addEventListener("click", () => {
|
||||
this.showAddForm = !this.showAddForm;
|
||||
this.formTitle = "";
|
||||
this.formDate = new Date().toISOString().slice(0, 10);
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Form submit
|
||||
this.shadow.querySelector("[data-action='submit']")?.addEventListener("click", () => {
|
||||
const title = (this.shadow.getElementById("rw-title") as HTMLInputElement)?.value || "";
|
||||
const date = (this.shadow.getElementById("rw-date") as HTMLInputElement)?.value || "";
|
||||
this.createReminder(title, date);
|
||||
});
|
||||
|
||||
// Form cancel
|
||||
this.shadow.querySelector("[data-action='cancel']")?.addEventListener("click", () => {
|
||||
this.showAddForm = false;
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Complete
|
||||
this.shadow.querySelectorAll<HTMLButtonElement>("[data-complete]").forEach(btn => {
|
||||
btn.addEventListener("click", () => this.completeReminder(btn.dataset.complete!));
|
||||
});
|
||||
|
||||
// Snooze
|
||||
this.shadow.querySelectorAll<HTMLButtonElement>("[data-snooze]").forEach(btn => {
|
||||
btn.addEventListener("click", () => this.snoozeReminder(btn.dataset.snooze!));
|
||||
});
|
||||
|
||||
// Delete
|
||||
this.shadow.querySelectorAll<HTMLButtonElement>("[data-delete]").forEach(btn => {
|
||||
btn.addEventListener("click", () => this.deleteReminder(btn.dataset.delete!));
|
||||
});
|
||||
|
||||
// Drop target on the whole widget
|
||||
const root = this.shadow.getElementById("widget-root");
|
||||
if (root) {
|
||||
root.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
root.classList.add("widget-drop-active");
|
||||
});
|
||||
root.addEventListener("dragleave", () => {
|
||||
root.classList.remove("widget-drop-active");
|
||||
});
|
||||
root.addEventListener("drop", (e) => this.handleDrop(e as DragEvent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-reminders-widget", FolkRemindersWidget);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ? ` • 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} • ${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 • <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">🔔 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 • <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" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue