feat(canvas): reminder scheduling UX — icon, context menu, drag-to-calendar, email notify

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 18:02:13 -07:00
parent 362bdd5857
commit e42fa5c5d2
2 changed files with 197 additions and 12 deletions

View File

@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -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; }
</style>
<div id="reminder-widget">
<div class="rw-header"><span class="rw-header-icon">🔔</span> Remind me of this on:</div>
@ -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 += `<div class="submenu" id="copy-space-submenu"><div class="submenu-loading">Loading spaces...</div></div>`;
html += `</div>`;
}
html += `<button data-action="schedule-reminder">📅 Schedule a reminder</button>`;
} else if (state === 'forgotten') {
html += `<button data-action="remember">Remember</button>`;
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 = `<span class="rw-shape-badge" style="background:${info.color}">${info.typeName}</span> ${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,11 +7076,20 @@
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 res = await fetch(`${schedBase}/api/reminders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
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,
@ -7007,12 +7098,17 @@
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(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 = `<span class="rw-shape-badge" style="background:${info.color}">${info.typeName}</span> ${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();