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;
|
||||
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,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 = `<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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue