From e42fa5c5d24d2232f9f67acba6da9123cbc68149 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 18:02:13 -0700 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20reminder=20scheduling=20UX=20?= =?UTF-8?q?=E2=80=94=20icon,=20context=20menu,=20drag-to-calendar,=20email?= =?UTF-8?q?=20notify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four reminder scheduling affordances to the canvas: - Floating 📅 icon on selected shapes toggles the reminder widget - Right-click "Schedule a reminder" context menu option - Drag-to-calendar compact mode (shows after 200ms of shape movement) - Email notification via EncryptID on reminder creation Closes TASK-122 Co-Authored-By: Claude Opus 4.6 --- ...ent-reminder-scheduling-UX-enhancements.md | 41 +++++ website/canvas.html | 168 ++++++++++++++++-- 2 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 backlog/tasks/task-122 - Canvas-element-reminder-scheduling-UX-enhancements.md diff --git a/backlog/tasks/task-122 - Canvas-element-reminder-scheduling-UX-enhancements.md b/backlog/tasks/task-122 - Canvas-element-reminder-scheduling-UX-enhancements.md new file mode 100644 index 0000000..4679af9 --- /dev/null +++ b/backlog/tasks/task-122 - Canvas-element-reminder-scheduling-UX-enhancements.md @@ -0,0 +1,41 @@ +--- +id: TASK-122 +title: Canvas element reminder scheduling UX enhancements +status: Done +assignee: [] +created_date: '2026-03-17 01:01' +labels: + - canvas + - rschedule + - UX +dependencies: [] +references: + - website/canvas.html +priority: medium +--- + +## Description + + +Add multiple UX affordances for scheduling reminders on canvas shapes: floating calendar icon on selected shapes, right-click context menu option, drag-to-calendar compact mode, and email notifications on reminder creation. + + +## Acceptance Criteria + +- [ ] #1 Floating 📅 icon appears near top-right of selected shape +- [ ] #2 Clicking calendar icon toggles the reminder widget +- [ ] #3 Right-click context menu shows 'Schedule a reminder' option +- [ ] #4 Context menu option opens reminder widget for the target shape +- [ ] #5 Dragging a shape for 200ms+ shows compact calendar in bottom-right +- [ ] #6 Hovering over calendar days during drag highlights them +- [ ] #7 Releasing shape over a highlighted day creates the reminder +- [ ] #8 Reminder API call includes notifyEmail when user email is available +- [ ] #9 Email is fetched from EncryptID and cached for session +- [ ] #10 Feedback message indicates email notification when applicable + + +## Final Summary + + +Implemented 4 reminder scheduling UX enhancements in `website/canvas.html` (156 insertions):\n\n1. **Right-click context menu** — \"📅 Schedule a reminder\" option in shape context menu opens reminder widget\n2. **Email notification** — Fetches user email from EncryptID `/auth/api/account/security`, caches it, passes `notifyEmail` to rSchedule API, shows confirmation in feedback\n3. **Floating calendar icon** — 28px circular 📅 button positioned at selected shape's top-right corner, repositions on scroll/zoom, toggles widget on click\n4. **Drag-to-calendar** — Compact calendar appears after 200ms of shape drag, day cells highlight on hover, releasing over a day creates the reminder + diff --git a/website/canvas.html b/website/canvas.html index c971039..ccf5967 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -2556,6 +2556,31 @@ text-align: center; font-size: 11px; color: #22c55e; font-weight: 600; margin-top: 6px; min-height: 16px; } + .shape-schedule-icon { + position: fixed; z-index: 9999; width: 28px; height: 28px; + border-radius: 50%; border: 1px solid #444; + background: var(--rs-bg-surface, #1e1e2e); color: #e0e0e0; + font-size: 14px; cursor: pointer; display: flex; + align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + opacity: 0; transition: opacity 0.15s; pointer-events: none; + padding: 0; line-height: 1; + } + .shape-schedule-icon.visible { + opacity: 1; pointer-events: auto; + } + .shape-schedule-icon:hover { + background: #2a2a3e; border-color: #818cf8; + } + #reminder-widget.rw-compact { + width: 200px; padding: 10px; top: auto; bottom: 16px; right: 16px; + border-color: #f59e0b; + } + #reminder-widget.rw-compact .rw-header, + #reminder-widget.rw-compact .rw-shape-label { font-size: 10px; margin-bottom: 4px; } + #reminder-widget.rw-compact .rw-day { font-size: 9px; } + #reminder-widget.rw-compact .rw-nav-btn { font-size: 11px; } + #reminder-widget.rw-compact .rw-month { font-size: 10px; }
🔔 Remind me of this on:
@@ -3262,6 +3287,39 @@ } __miCanvasBridge.setSelection([...selectedShapeIds]); updateReminderWidget(); + updateScheduleIcon(); + } + + // ── Floating schedule icon on selected shape ── + let scheduleIconEl = null; + function updateScheduleIcon() { + if (selectedShapeIds.size === 1) { + const id = [...selectedShapeIds][0]; + const el = document.getElementById(id); + if (el) { + if (!scheduleIconEl) { + scheduleIconEl = document.createElement("button"); + scheduleIconEl.className = "shape-schedule-icon"; + scheduleIconEl.textContent = "📅"; + scheduleIconEl.title = "Schedule a reminder"; + scheduleIconEl.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (rwWidget.classList.contains("visible")) { + rwWidget.classList.remove("visible"); + } else { + updateReminderWidget(); + } + }); + document.body.appendChild(scheduleIconEl); + } + const rect = el.getBoundingClientRect(); + scheduleIconEl.style.left = (rect.right + 4) + "px"; + scheduleIconEl.style.top = (rect.top - 4) + "px"; + scheduleIconEl.classList.add("visible"); + return; + } + } + if (scheduleIconEl) scheduleIconEl.classList.remove("visible"); } function rectsOverlapScreen(sel, r) { @@ -3746,6 +3804,10 @@ // Track position for group dragging shape.addEventListener("pointerdown", () => { shapeLastPos.set(shape.id, { x: shape.x, y: shape.y }); + onShapeMoveStart(shape); + }, { capture: true }); + shape.addEventListener("pointerup", () => { + onShapeMoveEnd(); }, { capture: true }); // Transform events (move, resize, rotate) @@ -5446,6 +5508,7 @@ html += ``; html += `
`; } + html += ``; } else if (state === 'forgotten') { html += ``; if (!alreadyForgotten) { @@ -5497,7 +5560,24 @@ return; } - // Group actions + + if (action === 'schedule-reminder') { + const targetId = contextTargetIds[0]; + const el = document.getElementById(targetId); + if (el) { + rwSelectedShape = el; + const info = getShapeInfo(el); + rwLabel.innerHTML = `${info.typeName} ${info.label}`; + rwWidget.classList.add('visible'); + rwFeedback.textContent = ''; + renderRwCalendar(); + } + shapeContextMenu.classList.remove('open'); + contextTargetIds = []; + return; + } + +// Group actions if (action === 'group-create') { const name = prompt("Group name:", "New Group") || "New Group"; groupManager.createGroup(name, contextTargetIds); @@ -5957,6 +6037,7 @@ } // Update remote cursors to match new camera position presence.setCamera(panX, panY, scale); + updateScheduleIcon(); } // Re-render canvas background when user changes preference @@ -6859,6 +6940,7 @@ let rwDate = new Date(); let rwSelectedShape = null; let rwDragShape = null; + let cachedUserEmail = null; const TYPE_COLORS = { "folk-markdown": "#6366f1", "folk-rapp": "#818cf8", "folk-embed": "#06b6d4", @@ -6994,25 +7076,39 @@ const remindAt = new Date(dateStr + "T09:00:00").getTime(); const schedBase = `/${communitySlug}/rschedule`; + // Fetch user email for notification (cached after first call) + if (cachedUserEmail === null) { + try { + const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); + if (sess?.accessToken) { + const r = await fetch('/auth/api/account/security', { headers: { Authorization: 'Bearer ' + sess.accessToken } }); + if (r.ok) { const d = await r.json(); cachedUserEmail = d.emailAddress || false; } + else cachedUserEmail = false; + } else cachedUserEmail = false; + } catch { cachedUserEmail = false; } + } + try { + const body = { + title: info.label, + remindAt, + allDay: true, + syncToCalendar: true, + sourceModule: info.moduleId || info.tag, + sourceEntityId: info.id, + sourceLabel: info.typeName, + sourceColor: info.color, + }; + if (cachedUserEmail) body.notifyEmail = cachedUserEmail; const res = await fetch(`${schedBase}/api/reminders`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - title: info.label, - remindAt, - allDay: true, - syncToCalendar: true, - sourceModule: info.moduleId || info.tag, - sourceEntityId: info.id, - sourceLabel: info.typeName, - sourceColor: info.color, - }), + body: JSON.stringify(body), }); if (res.ok) { const friendlyDate = new Date(dateStr + "T12:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); - rwFeedback.textContent = `✓ Reminder set for ${friendlyDate}`; + rwFeedback.textContent = cachedUserEmail ? `✓ Reminder set for ${friendlyDate} — email reminder will be sent` : `✓ Reminder set for ${friendlyDate}`; // Flash the day cell const dayEl = rwGrid.querySelector(`[data-rw-date="${dateStr}"]`); if (dayEl) { @@ -7033,6 +7129,54 @@ } } + // ── Drag-to-calendar: show compact calendar when shape is being moved ── + let dragCalendarActive = false; + let dragCalendarShape = null; + let dragMoveTimer = null; + let lastPointerPos = { x: 0, y: 0 }; + + // Track pointer globally for drop detection + document.addEventListener("pointermove", (e) => { lastPointerPos.x = e.clientX; lastPointerPos.y = e.clientY; }, { passive: true }); + + function onShapeMoveStart(shape) { + if (dragMoveTimer) clearTimeout(dragMoveTimer); + dragCalendarShape = shape; + dragMoveTimer = setTimeout(() => { + if (!dragCalendarShape) return; + dragCalendarActive = true; + rwSelectedShape = dragCalendarShape; + const info = getShapeInfo(dragCalendarShape); + rwLabel.innerHTML = `${info.typeName} ${info.label}`; + rwWidget.classList.add("visible", "rw-compact"); + rwFeedback.textContent = "Drop on a day to set reminder"; + renderRwCalendar(); + }, 200); + } + + function onShapeMoveEnd() { + if (dragMoveTimer) { clearTimeout(dragMoveTimer); dragMoveTimer = null; } + if (!dragCalendarActive) { dragCalendarShape = null; return; } + // Check if pointer is over a calendar day + const hitEl = document.elementFromPoint(lastPointerPos.x, lastPointerPos.y); + const dayEl = hitEl?.closest?.(".rw-day[data-rw-date]"); + if (dayEl && rwSelectedShape) { + createReminderForShape(dayEl.dataset.rwDate); + } + rwWidget.classList.remove("rw-compact"); + if (!dayEl) rwWidget.classList.remove("visible"); + dragCalendarActive = false; + dragCalendarShape = null; + } + + // Highlight calendar days on hover during drag + setInterval(() => { + if (!dragCalendarActive) return; + const hitEl = document.elementFromPoint(lastPointerPos.x, lastPointerPos.y); + rwGrid.querySelectorAll(".rw-day").forEach(d => d.classList.remove("drop-highlight")); + const dayEl = hitEl?.closest?.(".rw-day[data-rw-date]"); + if (dayEl) dayEl.classList.add("drop-highlight"); + }, 60); + rwPrev.addEventListener("click", () => { rwDate = new Date(rwDate.getFullYear(), rwDate.getMonth() - 1, 1); renderRwCalendar();