Repository navigation
Support undo after deleting event or task #1201
Description
Priority
Medium (nice to have)
Feature Description
Add an undo feature that enables users to restore a deleted event or task. After deletion, present an undo toast that can be clicked or dismissed via shortcut (CMD+Z on Mac, CTRL+Z on Windows) to restore the deleted entity with all its original properties.
The implementation should:
- Use optimistic rendering for immediate UI feedback, while ensuring data integrity between frontend and backend.
- Support hard deletes: actual removal from the back-end and Google Calendar. Restoration should create a new Google Calendar event, with the server issuing a new Google event ID. The frontend should only use server-provided event data and IDs for restored items.
- Not require the frontend to manage Google IDs separately.
Implementation details to address:
- How should IDs be handled? (Should the frontend create temporary IDs and expect the backend to supply new canonical IDs upon restore?)
- When restoring, what fields (like gEventId, gRecurringEventId) should be stripped before making the create call?
- How does the backend notify the frontend a deletion is confirmed? How should the frontend merge the freshly returned event data (including replacing temporary IDs)?
- Error handling: What happens if deletion or restoration fails? Should there be custom UX flows for feature errors, or should it fallback to global error handlers? Consider retry logic, user feedback and persistent/repeated failure notifications.
Use Case
Allows users to quickly reverse accidental deletion of calendar events or tasks, preserving productivity and preventing data loss.
Smooth undo via UI toast or shortcut results in less frustration and increased user trust.
Additional Context
Pattern to follow: backend manages Google Calendar lifecycle (delete/restore), and client only ever uses server-provided entity. On restore, backend creates a new Google event, returns fresh Google ID — client never tracks Google ID separately.
Further enhancements could later support soft deletions (trash bin, etc.), but this scope focuses on immediate hard delete/restore UX for tasks/events.
Example Code
This is a simplified example of how this flow would work. It uses soft-deletes, which is out-of-scope for this issue. However, it's being shown here for reference.
Frontend Delete Flow
async function deleteEvent(eventId: string) {
// 1. Optimistically update UI
setEvents(prev => prev.map(e =>
e.id === eventId
? { ...e, _pendingOperation: 'delete', status: 'deleted' }
: e
));
try {
// 2. Soft delete on server (keeps record)
const response = await api.delete(`/events/${eventId}`);
// 3. Server responds with updated state
// { success: true, deletedEventId: eventId }
// 4. Remove from UI after confirmed
setEvents(prev => prev.filter(e => e.id !== eventId));
// 5. Show undo toast with deleted event data cached
showUndoToast({ eventId, timeout: 5000 });
} catch (error) {
// Rollback on failure
setEvents(prev => prev.map(e =>
e.id === eventId
? { ...e, _pendingOperation: null, status: 'active' }
: e
));
showError('Delete failed');
}
}Backend API Design
// DELETE /api/events/:id
// Soft delete - mark as deleted but keep record
async function deleteEventEndpoint(req, res) {
const { id } = req.params;
// 1. Mark as deleted in DB (don't destroy)
await db.events.update(id, {
status: 'deleted',
deletedAt: new Date()
});
// 2. Delete from Google Calendar (external service)
if (event.googleEventId) {
await googleCalendar.delete(event.googleEventId);
}
res.json({ success: true, deletedEventId: id });
}
// POST /api/events/:id/restore
async function restoreEventEndpoint(req, res) {
const { id } = req.params;
// 1. Get deleted event
const deletedEvent = await db.events.findById(id);
// 2. Create NEW Google Calendar event (gets new Google ID)
let newGoogleEventId = null;
if (deletedEvent.wasInGoogleCalendar) {
const googleEvent = await googleCalendar.create({
summary: deletedEvent.title,
start: deletedEvent.start,
// ... other fields
});
newGoogleEventId = googleEvent.id;
}
// 3. Create NEW Compass event (gets new Compass ID)
const restoredEvent = await db.events.create({
...deletedEvent,
id: generateNewId(), // NEW ID
googleEventId: newGoogleEventId, // NEW Google ID
status: 'active',
deletedAt: null,
restoredFrom: id // Track lineage
});
// 4. Clean up old deleted record (optional - or keep for audit)
await db.events.hardDelete(id);
// 5. Return complete new event
res.json(restoredEvent);
}Frontend State Reconciliation
// Use a reconciliation approach
function reconcileEventState(serverEvent: Event) {
setEvents(prev => {
const index = prev.findIndex(e => e.id === serverEvent.id);
if (index >= 0) {
// Update existing
return prev.map((e, i) => i === index ? serverEvent : e);
} else {
// Add new (restored event case)
return [...prev, serverEvent];
}
});
}
// After any operation, fetch latest state
async function syncEvents() {
const events = await api.get('/events');
setEvents(events.filter(e => e.status === 'active'));
}
Activity