Skip to content

Support undo after deleting event or task #1201

Closed
2 of 2 issues completed
Closed

Description

@tyler-dane

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'));
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendWork related to improving the Compass APIwebFrontend/web related issue

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions