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:
parent
362bdd5857
commit
e42fa5c5d2
|
|
@ -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 -->
|
||||||
|
|
@ -2556,6 +2556,31 @@
|
||||||
text-align: center; font-size: 11px; color: #22c55e; font-weight: 600;
|
text-align: center; font-size: 11px; color: #22c55e; font-weight: 600;
|
||||||
margin-top: 6px; min-height: 16px;
|
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>
|
</style>
|
||||||
<div id="reminder-widget">
|
<div id="reminder-widget">
|
||||||
<div class="rw-header"><span class="rw-header-icon">🔔</span> Remind me of this on:</div>
|
<div class="rw-header"><span class="rw-header-icon">🔔</span> Remind me of this on:</div>
|
||||||
|
|
@ -3262,6 +3287,39 @@
|
||||||
}
|
}
|
||||||
__miCanvasBridge.setSelection([...selectedShapeIds]);
|
__miCanvasBridge.setSelection([...selectedShapeIds]);
|
||||||
updateReminderWidget();
|
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) {
|
function rectsOverlapScreen(sel, r) {
|
||||||
|
|
@ -3746,6 +3804,10 @@
|
||||||
// Track position for group dragging
|
// Track position for group dragging
|
||||||
shape.addEventListener("pointerdown", () => {
|
shape.addEventListener("pointerdown", () => {
|
||||||
shapeLastPos.set(shape.id, { x: shape.x, y: shape.y });
|
shapeLastPos.set(shape.id, { x: shape.x, y: shape.y });
|
||||||
|
onShapeMoveStart(shape);
|
||||||
|
}, { capture: true });
|
||||||
|
shape.addEventListener("pointerup", () => {
|
||||||
|
onShapeMoveEnd();
|
||||||
}, { capture: true });
|
}, { capture: true });
|
||||||
|
|
||||||
// Transform events (move, resize, rotate)
|
// 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 class="submenu" id="copy-space-submenu"><div class="submenu-loading">Loading spaces...</div></div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
}
|
}
|
||||||
|
html += `<button data-action="schedule-reminder">📅 Schedule a reminder</button>`;
|
||||||
} else if (state === 'forgotten') {
|
} else if (state === 'forgotten') {
|
||||||
html += `<button data-action="remember">Remember</button>`;
|
html += `<button data-action="remember">Remember</button>`;
|
||||||
if (!alreadyForgotten) {
|
if (!alreadyForgotten) {
|
||||||
|
|
@ -5497,7 +5560,24 @@
|
||||||
return;
|
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') {
|
if (action === 'group-create') {
|
||||||
const name = prompt("Group name:", "New Group") || "New Group";
|
const name = prompt("Group name:", "New Group") || "New Group";
|
||||||
groupManager.createGroup(name, contextTargetIds);
|
groupManager.createGroup(name, contextTargetIds);
|
||||||
|
|
@ -5957,6 +6037,7 @@
|
||||||
}
|
}
|
||||||
// Update remote cursors to match new camera position
|
// Update remote cursors to match new camera position
|
||||||
presence.setCamera(panX, panY, scale);
|
presence.setCamera(panX, panY, scale);
|
||||||
|
updateScheduleIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-render canvas background when user changes preference
|
// Re-render canvas background when user changes preference
|
||||||
|
|
@ -6859,6 +6940,7 @@
|
||||||
let rwDate = new Date();
|
let rwDate = new Date();
|
||||||
let rwSelectedShape = null;
|
let rwSelectedShape = null;
|
||||||
let rwDragShape = null;
|
let rwDragShape = null;
|
||||||
|
let cachedUserEmail = null;
|
||||||
|
|
||||||
const TYPE_COLORS = {
|
const TYPE_COLORS = {
|
||||||
"folk-markdown": "#6366f1", "folk-rapp": "#818cf8", "folk-embed": "#06b6d4",
|
"folk-markdown": "#6366f1", "folk-rapp": "#818cf8", "folk-embed": "#06b6d4",
|
||||||
|
|
@ -6994,25 +7076,39 @@
|
||||||
const remindAt = new Date(dateStr + "T09:00:00").getTime();
|
const remindAt = new Date(dateStr + "T09:00:00").getTime();
|
||||||
const schedBase = `/${communitySlug}/rschedule`;
|
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 {
|
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`, {
|
const res = await fetch(`${schedBase}/api/reminders`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
title: info.label,
|
|
||||||
remindAt,
|
|
||||||
allDay: true,
|
|
||||||
syncToCalendar: true,
|
|
||||||
sourceModule: info.moduleId || info.tag,
|
|
||||||
sourceEntityId: info.id,
|
|
||||||
sourceLabel: info.typeName,
|
|
||||||
sourceColor: info.color,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const friendlyDate = new Date(dateStr + "T12:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
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
|
// Flash the day cell
|
||||||
const dayEl = rwGrid.querySelector(`[data-rw-date="${dateStr}"]`);
|
const dayEl = rwGrid.querySelector(`[data-rw-date="${dateStr}"]`);
|
||||||
if (dayEl) {
|
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", () => {
|
rwPrev.addEventListener("click", () => {
|
||||||
rwDate = new Date(rwDate.getFullYear(), rwDate.getMonth() - 1, 1);
|
rwDate = new Date(rwDate.getFullYear(), rwDate.getMonth() - 1, 1);
|
||||||
renderRwCalendar();
|
renderRwCalendar();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue