feat(calendar): add drag-create, resize, recurring scope, quick-create, and duplication

- Click-drag on empty time slots to create events with pre-filled time range
- Resize events by dragging bottom edge (15-min snap, optimistic JMAP update)
- Recurring event edit/delete scope dialog (this/following/all occurrences)
- Double-click quick event creation with inline title input
- Event duplication button in modal (+1 day offset)
- Shared interaction hook for pointer-based calendar interactions

Fixes #13
This commit is contained in:
Matthieu MALVACHE
2026-02-22 17:32:10 +01:00
committed by Matthieu MALVACHE
parent fb7cfe1634
commit ccbab654f3
22 changed files with 2011 additions and 322 deletions
+31 -1
View File
@@ -1177,9 +1177,21 @@
"all_day": "Ganztägig",
"more": "+{count} weitere",
"no_title": "(Kein Titel)",
"resize": "Termingröße ändern",
"duplicate": "Duplizieren",
"today_header": "Heute",
"tomorrow_header": "Morgen"
},
"detail": {
"add_note": "Notiz hinzufügen...",
"save_note": "Speichern",
"note_saved": "Notiz hinzugefügt",
"open_link": "Link öffnen",
"meeting_link": "Besprechungslink",
"tentative": "Vorläufig",
"cancelled": "Abgesagt",
"delete_confirm": "Dieses Ereignis löschen?"
},
"form": {
"title": "Titel",
"description": "Beschreibung",
@@ -1230,6 +1242,17 @@
"until": "Bis",
"occurrences": "{count} Wiederholungen"
},
"recurrence_scope": {
"edit_title": "Wiederkehrendes Ereignis bearbeiten",
"delete_title": "Wiederkehrendes Ereignis löschen",
"description": "Dies ist ein wiederkehrendes Ereignis. Welche Ereignisse möchten Sie ändern?",
"this_event": "Nur dieses Ereignis",
"this_and_future": "Dieses und folgende Ereignisse",
"all_events": "Alle Ereignisse",
"cancel": "Abbrechen",
"save": "Speichern",
"delete": "Löschen"
},
"alerts": {
"title": "Erinnerung",
"none": "Keine Erinnerung",
@@ -1275,17 +1298,24 @@
"calendar_created": "Kalender erstellt",
"calendar_deleted": "Kalender gelöscht",
"event_move_error": "Termin konnte nicht verschoben werden",
"event_resize_error": "Termingröße konnte nicht geändert werden",
"alert_title": "Bevorstehender Termin",
"alert_now": "Beginnt jetzt",
"alert_in_minutes": "In {count} Min.",
"invitation_sent": "Einladungen gesendet",
"rsvp_updated": "Antwort aktualisiert",
"rsvp_error": "Antwort konnte nicht aktualisiert werden"
"rsvp_error": "Antwort konnte nicht aktualisiert werden",
"event_duplicated": "Termin dupliziert",
"event_error": "Termin konnte nicht gespeichert werden"
},
"status": {
"loading_calendars": "Kalender werden geladen...",
"loading_events": "Termine werden geladen..."
},
"quick_create": {
"placeholder": "Titel des neuen Termins",
"aria_label": "Schnellerstellung eines Termins"
},
"nav_prev": "Zurück",
"nav_next": "Weiter",
"import": {
+31 -1
View File
@@ -1182,9 +1182,21 @@
"all_day": "All day",
"more": "+{count} more",
"no_title": "(No title)",
"resize": "Resize event",
"duplicate": "Duplicate",
"today_header": "Today",
"tomorrow_header": "Tomorrow"
},
"detail": {
"add_note": "Add a note...",
"save_note": "Save",
"note_saved": "Note added",
"open_link": "Open link",
"meeting_link": "Meeting link",
"tentative": "Tentative",
"cancelled": "Cancelled",
"delete_confirm": "Delete this event?"
},
"form": {
"title": "Title",
"description": "Description",
@@ -1235,6 +1247,17 @@
"until": "Until",
"occurrences": "{count} occurrences"
},
"recurrence_scope": {
"edit_title": "Edit recurring event",
"delete_title": "Delete recurring event",
"description": "This is a recurring event. Which events would you like to modify?",
"this_event": "This event only",
"this_and_future": "This and following events",
"all_events": "All events",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete"
},
"alerts": {
"title": "Reminder",
"none": "No reminder",
@@ -1280,17 +1303,24 @@
"calendar_created": "Calendar created",
"calendar_deleted": "Calendar deleted",
"event_move_error": "Failed to move event",
"event_resize_error": "Failed to resize event",
"alert_title": "Upcoming event",
"alert_now": "Starting now",
"alert_in_minutes": "In {count} min",
"invitation_sent": "Invitations sent",
"rsvp_updated": "Response updated",
"rsvp_error": "Failed to update response"
"rsvp_error": "Failed to update response",
"event_duplicated": "Event duplicated",
"event_error": "Failed to save event"
},
"status": {
"loading_calendars": "Loading calendars...",
"loading_events": "Loading events..."
},
"quick_create": {
"placeholder": "New event title",
"aria_label": "Quick create event"
},
"nav_prev": "Previous",
"nav_next": "Next",
"import": {
+31 -1
View File
@@ -1177,9 +1177,21 @@
"all_day": "Todo el día",
"more": "+{count} más",
"no_title": "(Sin título)",
"resize": "Redimensionar evento",
"duplicate": "Duplicar",
"today_header": "Hoy",
"tomorrow_header": "Mañana"
},
"detail": {
"add_note": "Añadir una nota...",
"save_note": "Guardar",
"note_saved": "Nota añadida",
"open_link": "Abrir enlace",
"meeting_link": "Enlace de reunión",
"tentative": "Provisional",
"cancelled": "Cancelado",
"delete_confirm": "¿Eliminar este evento?"
},
"form": {
"title": "Título",
"description": "Descripción",
@@ -1230,6 +1242,17 @@
"until": "Hasta",
"occurrences": "{count} repeticiones"
},
"recurrence_scope": {
"edit_title": "Editar evento recurrente",
"delete_title": "Eliminar evento recurrente",
"description": "Este es un evento recurrente. ¿Qué eventos desea modificar?",
"this_event": "Solo este evento",
"this_and_future": "Este evento y los siguientes",
"all_events": "Todos los eventos",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar"
},
"alerts": {
"title": "Recordatorio",
"none": "Sin recordatorio",
@@ -1275,17 +1298,24 @@
"calendar_created": "Calendario creado",
"calendar_deleted": "Calendario eliminado",
"event_move_error": "Error al mover el evento",
"event_resize_error": "Error al redimensionar el evento",
"alert_title": "Evento próximo",
"alert_now": "Comienza ahora",
"alert_in_minutes": "En {count} min",
"invitation_sent": "Invitaciones enviadas",
"rsvp_updated": "Respuesta actualizada",
"rsvp_error": "Error al actualizar la respuesta"
"rsvp_error": "Error al actualizar la respuesta",
"event_duplicated": "Evento duplicado",
"event_error": "Error al guardar el evento"
},
"status": {
"loading_calendars": "Cargando calendarios...",
"loading_events": "Cargando eventos..."
},
"quick_create": {
"placeholder": "Título del nuevo evento",
"aria_label": "Creación rápida de evento"
},
"nav_prev": "Anterior",
"nav_next": "Siguiente",
"import": {
+31 -1
View File
@@ -1177,9 +1177,21 @@
"all_day": "Toute la journée",
"more": "+{count} de plus",
"no_title": "(Sans titre)",
"resize": "Redimensionner l'événement",
"duplicate": "Dupliquer",
"today_header": "Aujourd'hui",
"tomorrow_header": "Demain"
},
"detail": {
"add_note": "Ajouter une note...",
"save_note": "Enregistrer",
"note_saved": "Note ajoutée",
"open_link": "Ouvrir le lien",
"meeting_link": "Lien de réunion",
"tentative": "Provisoire",
"cancelled": "Annulé",
"delete_confirm": "Supprimer cet événement ?"
},
"form": {
"title": "Titre",
"description": "Description",
@@ -1230,6 +1242,17 @@
"until": "Jusqu'au",
"occurrences": "{count} occurrences"
},
"recurrence_scope": {
"edit_title": "Modifier l'événement récurrent",
"delete_title": "Supprimer l'événement récurrent",
"description": "Ceci est un événement récurrent. Quels événements souhaitez-vous modifier ?",
"this_event": "Cet événement uniquement",
"this_and_future": "Cet événement et les suivants",
"all_events": "Tous les événements",
"cancel": "Annuler",
"save": "Enregistrer",
"delete": "Supprimer"
},
"alerts": {
"title": "Rappel",
"none": "Aucun rappel",
@@ -1275,17 +1298,24 @@
"calendar_created": "Calendrier créé",
"calendar_deleted": "Calendrier supprimé",
"event_move_error": "Échec du déplacement de l'événement",
"event_resize_error": "Échec du redimensionnement de l'événement",
"alert_title": "Événement à venir",
"alert_now": "Commence maintenant",
"alert_in_minutes": "Dans {count} min",
"invitation_sent": "Invitations envoyées",
"rsvp_updated": "Réponse mise à jour",
"rsvp_error": "Échec de la mise à jour de la réponse"
"rsvp_error": "Échec de la mise à jour de la réponse",
"event_duplicated": "Événement dupliqué",
"event_error": "Échec de l'enregistrement de l'événement"
},
"status": {
"loading_calendars": "Chargement des calendriers...",
"loading_events": "Chargement des événements..."
},
"quick_create": {
"placeholder": "Titre du nouvel événement",
"aria_label": "Création rapide d'événement"
},
"nav_prev": "Précédent",
"nav_next": "Suivant",
"import": {
+31 -1
View File
@@ -1177,9 +1177,21 @@
"all_day": "Tutto il giorno",
"more": "+{count} altri",
"no_title": "(Senza titolo)",
"resize": "Ridimensiona evento",
"duplicate": "Duplica",
"today_header": "Oggi",
"tomorrow_header": "Domani"
},
"detail": {
"add_note": "Aggiungi una nota...",
"save_note": "Salva",
"note_saved": "Nota aggiunta",
"open_link": "Apri link",
"meeting_link": "Link riunione",
"tentative": "Provvisorio",
"cancelled": "Annullato",
"delete_confirm": "Eliminare questo evento?"
},
"form": {
"title": "Titolo",
"description": "Descrizione",
@@ -1230,6 +1242,17 @@
"until": "Fino al",
"occurrences": "{count} ripetizioni"
},
"recurrence_scope": {
"edit_title": "Modifica evento ricorrente",
"delete_title": "Elimina evento ricorrente",
"description": "Questo è un evento ricorrente. Quali eventi desideri modificare?",
"this_event": "Solo questo evento",
"this_and_future": "Questo evento e i seguenti",
"all_events": "Tutti gli eventi",
"cancel": "Annulla",
"save": "Salva",
"delete": "Elimina"
},
"alerts": {
"title": "Promemoria",
"none": "Nessun promemoria",
@@ -1275,17 +1298,24 @@
"calendar_created": "Calendario creato",
"calendar_deleted": "Calendario eliminato",
"event_move_error": "Spostamento dell'evento non riuscito",
"event_resize_error": "Ridimensionamento dell'evento non riuscito",
"alert_title": "Evento in arrivo",
"alert_now": "Inizia ora",
"alert_in_minutes": "Tra {count} min",
"invitation_sent": "Inviti inviati",
"rsvp_updated": "Risposta aggiornata",
"rsvp_error": "Impossibile aggiornare la risposta"
"rsvp_error": "Impossibile aggiornare la risposta",
"event_duplicated": "Evento duplicato",
"event_error": "Salvataggio dell'evento non riuscito"
},
"status": {
"loading_calendars": "Caricamento calendari...",
"loading_events": "Caricamento eventi..."
},
"quick_create": {
"placeholder": "Titolo del nuovo evento",
"aria_label": "Creazione rapida evento"
},
"nav_prev": "Precedente",
"nav_next": "Successivo",
"import": {
+31 -1
View File
@@ -1177,9 +1177,21 @@
"all_day": "終日",
"more": "他{count}件",
"no_title": "(タイトルなし)",
"resize": "イベントのサイズ変更",
"duplicate": "複製",
"today_header": "今日",
"tomorrow_header": "明日"
},
"detail": {
"add_note": "メモを追加...",
"save_note": "保存",
"note_saved": "メモを追加しました",
"open_link": "リンクを開く",
"meeting_link": "会議リンク",
"tentative": "仮",
"cancelled": "キャンセル済み",
"delete_confirm": "このイベントを削除しますか?"
},
"form": {
"title": "タイトル",
"description": "説明",
@@ -1230,6 +1242,17 @@
"until": "終了日",
"occurrences": "{count}回"
},
"recurrence_scope": {
"edit_title": "繰り返しイベントを編集",
"delete_title": "繰り返しイベントを削除",
"description": "これは繰り返しイベントです。どのイベントを変更しますか?",
"this_event": "このイベントのみ",
"this_and_future": "これ以降のすべてのイベント",
"all_events": "すべてのイベント",
"cancel": "キャンセル",
"save": "保存",
"delete": "削除"
},
"alerts": {
"title": "リマインダー",
"none": "リマインダーなし",
@@ -1275,17 +1298,24 @@
"calendar_created": "カレンダーを作成しました",
"calendar_deleted": "カレンダーを削除しました",
"event_move_error": "イベントの移動に失敗しました",
"event_resize_error": "イベントのサイズ変更に失敗しました",
"alert_title": "予定のイベント",
"alert_now": "まもなく開始",
"alert_in_minutes": "{count}分後",
"invitation_sent": "招待を送信しました",
"rsvp_updated": "回答を更新しました",
"rsvp_error": "回答の更新に失敗しました"
"rsvp_error": "回答の更新に失敗しました",
"event_duplicated": "予定を複製しました",
"event_error": "予定の保存に失敗しました"
},
"status": {
"loading_calendars": "カレンダーを読み込み中...",
"loading_events": "予定を読み込み中..."
},
"quick_create": {
"placeholder": "新しい予定のタイトル",
"aria_label": "予定をすばやく作成"
},
"nav_prev": "前へ",
"nav_next": "次へ",
"import": {
+31 -1
View File
@@ -1177,9 +1177,21 @@
"all_day": "Hele dag",
"more": "+{count} meer",
"no_title": "(Geen titel)",
"resize": "Evenement formaat wijzigen",
"duplicate": "Dupliceren",
"today_header": "Vandaag",
"tomorrow_header": "Morgen"
},
"detail": {
"add_note": "Notitie toevoegen...",
"save_note": "Opslaan",
"note_saved": "Notitie toegevoegd",
"open_link": "Link openen",
"meeting_link": "Vergaderlink",
"tentative": "Voorlopig",
"cancelled": "Geannuleerd",
"delete_confirm": "Dit evenement verwijderen?"
},
"form": {
"title": "Titel",
"description": "Beschrijving",
@@ -1230,6 +1242,17 @@
"until": "Tot",
"occurrences": "{count} herhalingen"
},
"recurrence_scope": {
"edit_title": "Terugkerend evenement bewerken",
"delete_title": "Terugkerend evenement verwijderen",
"description": "Dit is een terugkerend evenement. Welke evenementen wilt u wijzigen?",
"this_event": "Alleen dit evenement",
"this_and_future": "Dit en volgende evenementen",
"all_events": "Alle evenementen",
"cancel": "Annuleren",
"save": "Opslaan",
"delete": "Verwijderen"
},
"alerts": {
"title": "Herinnering",
"none": "Geen herinnering",
@@ -1275,17 +1298,24 @@
"calendar_created": "Agenda aangemaakt",
"calendar_deleted": "Agenda verwijderd",
"event_move_error": "Evenement verplaatsen mislukt",
"event_resize_error": "Evenement formaat wijzigen mislukt",
"alert_title": "Aankomend evenement",
"alert_now": "Begint nu",
"alert_in_minutes": "Over {count} min",
"invitation_sent": "Uitnodigingen verzonden",
"rsvp_updated": "Reactie bijgewerkt",
"rsvp_error": "Reactie kon niet worden bijgewerkt"
"rsvp_error": "Reactie kon niet worden bijgewerkt",
"event_duplicated": "Evenement gedupliceerd",
"event_error": "Evenement opslaan mislukt"
},
"status": {
"loading_calendars": "Agenda's laden...",
"loading_events": "Evenementen laden..."
},
"quick_create": {
"placeholder": "Titel nieuw evenement",
"aria_label": "Snel evenement aanmaken"
},
"nav_prev": "Vorige",
"nav_next": "Volgende",
"import": {
+31 -1
View File
@@ -1177,9 +1177,21 @@
"all_day": "Dia inteiro",
"more": "+{count} mais",
"no_title": "(Sem título)",
"resize": "Redimensionar evento",
"duplicate": "Duplicar",
"today_header": "Hoje",
"tomorrow_header": "Amanhã"
},
"detail": {
"add_note": "Adicionar uma nota...",
"save_note": "Salvar",
"note_saved": "Nota adicionada",
"open_link": "Abrir link",
"meeting_link": "Link da reunião",
"tentative": "Provisório",
"cancelled": "Cancelado",
"delete_confirm": "Excluir este evento?"
},
"form": {
"title": "Título",
"description": "Descrição",
@@ -1230,6 +1242,17 @@
"until": "Até",
"occurrences": "{count} repetições"
},
"recurrence_scope": {
"edit_title": "Editar evento recorrente",
"delete_title": "Excluir evento recorrente",
"description": "Este é um evento recorrente. Quais eventos você deseja modificar?",
"this_event": "Apenas este evento",
"this_and_future": "Este evento e os seguintes",
"all_events": "Todos os eventos",
"cancel": "Cancelar",
"save": "Salvar",
"delete": "Excluir"
},
"alerts": {
"title": "Lembrete",
"none": "Sem lembrete",
@@ -1275,17 +1298,24 @@
"calendar_created": "Calendário criado",
"calendar_deleted": "Calendário excluído",
"event_move_error": "Falha ao mover o evento",
"event_resize_error": "Falha ao redimensionar o evento",
"alert_title": "Evento próximo",
"alert_now": "Começa agora",
"alert_in_minutes": "Em {count} min",
"invitation_sent": "Convites enviados",
"rsvp_updated": "Resposta atualizada",
"rsvp_error": "Falha ao atualizar resposta"
"rsvp_error": "Falha ao atualizar resposta",
"event_duplicated": "Evento duplicado",
"event_error": "Falha ao salvar o evento"
},
"status": {
"loading_calendars": "Carregando calendários...",
"loading_events": "Carregando eventos..."
},
"quick_create": {
"placeholder": "Título do novo evento",
"aria_label": "Criação rápida de evento"
},
"nav_prev": "Anterior",
"nav_next": "Próximo",
"import": {