Przeglądaj źródła

Group a backfill into one activity row with counts, open pulled calendar events in place, and keep the vendor's event link (#335)

Bernt Christian Egeland 3 tygodni temu
rodzic
commit
595788d21d
37 zmienionych plików z 855 dodań i 158 usunięć
  1. 10 1
      messages/de/calendar.json
  2. 14 2
      messages/de/integrations.json
  3. 10 1
      messages/en/calendar.json
  4. 14 2
      messages/en/integrations.json
  5. 10 1
      messages/es/calendar.json
  6. 14 2
      messages/es/integrations.json
  7. 10 1
      messages/fr/calendar.json
  8. 14 2
      messages/fr/integrations.json
  9. 10 1
      messages/it/calendar.json
  10. 14 2
      messages/it/integrations.json
  11. 10 1
      messages/lt/calendar.json
  12. 14 2
      messages/lt/integrations.json
  13. 10 1
      messages/nb/calendar.json
  14. 14 2
      messages/nb/integrations.json
  15. 10 1
      messages/nl/calendar.json
  16. 14 2
      messages/nl/integrations.json
  17. 10 1
      messages/pl/calendar.json
  18. 14 2
      messages/pl/integrations.json
  19. 10 1
      messages/pt-BR/calendar.json
  20. 14 2
      messages/pt-BR/integrations.json
  21. 10 1
      messages/ru/calendar.json
  22. 14 2
      messages/ru/integrations.json
  23. 10 1
      messages/tr/calendar.json
  24. 14 2
      messages/tr/integrations.json
  25. 3 0
      prisma/migrations/20260906120000_external_event_remote_url/migration.sql
  26. 2 0
      prisma/schema/integrations.prisma
  27. 188 52
      src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx
  28. 1 1
      src/app/(authenticated)/settings/integrations/[connector]/page.tsx
  29. 20 1
      src/features/calendar/Actions/calendarActions.ts
  30. 10 1
      src/features/calendar/Components/CalendarDayCell.tsx
  31. 139 38
      src/features/calendar/Components/CalendarEventList.tsx
  32. 5 1
      src/features/calendar/Components/calendar-utils.ts
  33. 168 26
      src/features/integrations/Actions/integrationActions.ts
  34. 3 0
      src/features/integrations/Lib/calendar-sync.ts
  35. 23 0
      src/features/integrations/Lib/types.ts
  36. 3 1
      src/integrations/google-calendar/server.ts
  37. 2 1
      src/integrations/microsoft-365/server.ts

+ 10 - 1
messages/de/calendar.json

@@ -77,7 +77,16 @@
       "message": "Nachricht"
       "message": "Nachricht"
     },
     },
     "noEvents": "Keine Ereignisse",
     "noEvents": "Keine Ereignisse",
-    "selectDay": "Wählen Sie einen Tag mit Ereignissen oder erstellen Sie einen neuen Arbeitsauftrag."
+    "selectDay": "Wählen Sie einen Tag mit Ereignissen oder erstellen Sie einen neuen Arbeitsauftrag.",
+    "external": {
+      "source": "Aus {source}",
+      "sourceUnknown": "Aus einem verbundenen Kalender",
+      "allDay": "Ganztägig",
+      "open": "In {source} öffnen",
+      "openGeneric": "Im Kalender öffnen",
+      "noLink": "Dieser Kalender liefert keinen Link zum Termin.",
+      "readOnly": "Belegte Zeit aus einem verbundenen Kalender. Änderungen dort erscheinen hier bei der nächsten Synchronisierung."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/de/integrations.json

@@ -147,7 +147,8 @@
       "off": "Einstellung einschalten und speichern; bis dahin prüft ein Durchlauf nichts.",
       "off": "Einstellung einschalten und speichern; bis dahin prüft ein Durchlauf nichts.",
       "syncNowHelp": "Jetzt synchronisieren startet sofort einen Durchlauf statt auf den stündlichen zu warten. Er fragt 25 Fahrzeuge auf einmal ab und macht von selbst weiter, bis alle fälligen Fahrzeuge erledigt sind."
       "syncNowHelp": "Jetzt synchronisieren startet sofort einen Durchlauf statt auf den stündlichen zu warten. Er fragt 25 Fahrzeuge auf einmal ab und macht von selbst weiter, bis alle fälligen Fahrzeuge erledigt sind."
     },
     },
-    "supersedes": "Wenn Sie {next} verbinden, wird {current} getrennt. Nur einer kann zuständig sein: Sobald {next} aktiv ist, wird {current} nicht mehr genutzt."
+    "supersedes": "Wenn Sie {next} verbinden, wird {current} getrennt. Nur einer kann zuständig sein: Sobald {next} aktiv ist, wird {current} nicht mehr genutzt.",
+    "batchRetried": "{count, plural, =0 {Nichts zu wiederholen} one {# Auftrag erneut eingereiht} other {# Aufträge erneut eingereiht}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Aktivität",
     "title": "Aktivität",
@@ -159,7 +160,18 @@
       "done": "Erledigt",
       "done": "Erledigt",
       "failed": "Fehlgeschlagen",
       "failed": "Fehlgeschlagen",
       "dead": "Aufgegeben"
       "dead": "Aufgegeben"
-    }
+    },
+    "jobs": "{count, plural, one {# Auftrag} other {# Aufträge}}",
+    "counts": {
+      "queued": "{count} in Warteschlange",
+      "running": "{count} laufen",
+      "done": "{count} erledigt",
+      "failed": "{count} fehlgeschlagen",
+      "dead": "{count} aufgegeben"
+    },
+    "noFailures": "Nichts ist fehlgeschlagen.",
+    "retryFailed": "Fehlgeschlagene wiederholen",
+    "details": "Details anzeigen"
   },
   },
   "errors": {
   "errors": {
     "generic": "Etwas ist schiefgelaufen",
     "generic": "Etwas ist schiefgelaufen",

+ 10 - 1
messages/en/calendar.json

@@ -77,7 +77,16 @@
       "message": "Message"
       "message": "Message"
     },
     },
     "noEvents": "No events",
     "noEvents": "No events",
-    "selectDay": "Select a day with events or create a new work order."
+    "selectDay": "Select a day with events or create a new work order.",
+    "external": {
+      "source": "From {source}",
+      "sourceUnknown": "From a connected calendar",
+      "allDay": "All day",
+      "open": "Open in {source}",
+      "openGeneric": "Open in calendar",
+      "noLink": "This calendar does not give a link to the event.",
+      "readOnly": "Busy time from a connected calendar. Change it there and it updates here on the next sync."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/en/integrations.json

@@ -147,7 +147,8 @@
       "off": "Turn the setting on and save; until then a pass checks nothing.",
       "off": "Turn the setting on and save; until then a pass checks nothing.",
       "syncNowHelp": "Sync now starts a pass straight away instead of waiting for the hourly one. It looks up 25 vehicles at a time and continues on its own until every due vehicle is done."
       "syncNowHelp": "Sync now starts a pass straight away instead of waiting for the hourly one. It looks up 25 vehicles at a time and continues on its own until every due vehicle is done."
     },
     },
-    "supersedes": "Connecting {next} disconnects {current}. Only one at a time can be in charge, so {current} stops being used the moment {next} goes live."
+    "supersedes": "Connecting {next} disconnects {current}. Only one at a time can be in charge, so {current} stops being used the moment {next} goes live.",
+    "batchRetried": "{count, plural, =0 {Nothing to retry} one {# job queued again} other {# jobs queued again}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Activity",
     "title": "Activity",
@@ -159,7 +160,18 @@
       "done": "Done",
       "done": "Done",
       "failed": "Failed",
       "failed": "Failed",
       "dead": "Gave up"
       "dead": "Gave up"
-    }
+    },
+    "jobs": "{count, plural, one {# job} other {# jobs}}",
+    "counts": {
+      "queued": "{count} queued",
+      "running": "{count} running",
+      "done": "{count} done",
+      "failed": "{count} failed",
+      "dead": "{count} gave up"
+    },
+    "noFailures": "Nothing failed.",
+    "retryFailed": "Retry failed jobs",
+    "details": "Show details"
   },
   },
   "errors": {
   "errors": {
     "generic": "Something went wrong",
     "generic": "Something went wrong",

+ 10 - 1
messages/es/calendar.json

@@ -77,7 +77,16 @@
       "message": "Mensaje"
       "message": "Mensaje"
     },
     },
     "noEvents": "Sin eventos",
     "noEvents": "Sin eventos",
-    "selectDay": "Seleccione un día con eventos o cree una nueva orden de trabajo."
+    "selectDay": "Seleccione un día con eventos o cree una nueva orden de trabajo.",
+    "external": {
+      "source": "De {source}",
+      "sourceUnknown": "De un calendario conectado",
+      "allDay": "Todo el día",
+      "open": "Abrir en {source}",
+      "openGeneric": "Abrir en el calendario",
+      "noLink": "Este calendario no ofrece un enlace al evento.",
+      "readOnly": "Tiempo ocupado de un calendario conectado. Cámbialo allí y se actualizará aquí en la próxima sincronización."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/es/integrations.json

@@ -147,7 +147,8 @@
       "off": "Activa el ajuste y guarda; hasta entonces una pasada no comprueba nada.",
       "off": "Activa el ajuste y guarda; hasta entonces una pasada no comprueba nada.",
       "syncNowHelp": "Sincronizar ahora inicia una pasada de inmediato en vez de esperar a la horaria. Consulta 25 vehículos cada vez y continúa sola hasta terminar con todos los pendientes."
       "syncNowHelp": "Sincronizar ahora inicia una pasada de inmediato en vez de esperar a la horaria. Consulta 25 vehículos cada vez y continúa sola hasta terminar con todos los pendientes."
     },
     },
-    "supersedes": "Conectar {next} desconecta {current}. Solo uno puede estar activo a la vez, así que {current} deja de usarse en cuanto {next} entra en servicio."
+    "supersedes": "Conectar {next} desconecta {current}. Solo uno puede estar activo a la vez, así que {current} deja de usarse en cuanto {next} entra en servicio.",
+    "batchRetried": "{count, plural, =0 {Nada que reintentar} one {# tarea vuelta a la cola} other {# tareas vueltas a la cola}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Actividad",
     "title": "Actividad",
@@ -159,7 +160,18 @@
       "done": "Hecho",
       "done": "Hecho",
       "failed": "Fallido",
       "failed": "Fallido",
       "dead": "Abandonado"
       "dead": "Abandonado"
-    }
+    },
+    "jobs": "{count, plural, one {# tarea} other {# tareas}}",
+    "counts": {
+      "queued": "{count} en cola",
+      "running": "{count} en curso",
+      "done": "{count} hechas",
+      "failed": "{count} fallidas",
+      "dead": "{count} abandonadas"
+    },
+    "noFailures": "Nada ha fallado.",
+    "retryFailed": "Reintentar las fallidas",
+    "details": "Ver detalles"
   },
   },
   "errors": {
   "errors": {
     "generic": "Algo salió mal",
     "generic": "Algo salió mal",

+ 10 - 1
messages/fr/calendar.json

@@ -77,7 +77,16 @@
       "message": "Message"
       "message": "Message"
     },
     },
     "noEvents": "Aucun événement",
     "noEvents": "Aucun événement",
-    "selectDay": "Sélectionnez un jour avec des événements ou créez un nouvel ordre de travail."
+    "selectDay": "Sélectionnez un jour avec des événements ou créez un nouvel ordre de travail.",
+    "external": {
+      "source": "Depuis {source}",
+      "sourceUnknown": "Depuis un calendrier connecté",
+      "allDay": "Toute la journée",
+      "open": "Ouvrir dans {source}",
+      "openGeneric": "Ouvrir dans le calendrier",
+      "noLink": "Ce calendrier ne fournit pas de lien vers l'événement.",
+      "readOnly": "Créneau occupé venant d'un calendrier connecté. Modifiez-le là-bas, il sera mis à jour ici à la prochaine synchronisation."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/fr/integrations.json

@@ -147,7 +147,8 @@
       "off": "Activez le réglage et enregistrez ; d'ici là, un passage ne vérifie rien.",
       "off": "Activez le réglage et enregistrez ; d'ici là, un passage ne vérifie rien.",
       "syncNowHelp": "Synchroniser maintenant lance un passage immédiatement au lieu d'attendre celui de l'heure. Il recherche 25 véhicules à la fois et continue seul jusqu'à ce que tous les véhicules à vérifier soient traités."
       "syncNowHelp": "Synchroniser maintenant lance un passage immédiatement au lieu d'attendre celui de l'heure. Il recherche 25 véhicules à la fois et continue seul jusqu'à ce que tous les véhicules à vérifier soient traités."
     },
     },
-    "supersedes": "Connecter {next} déconnecte {current}. Un seul peut être actif à la fois : {current} cesse d'être utilisé dès que {next} est en service."
+    "supersedes": "Connecter {next} déconnecte {current}. Un seul peut être actif à la fois : {current} cesse d'être utilisé dès que {next} est en service.",
+    "batchRetried": "{count, plural, =0 {Rien à réessayer} one {# tâche remise en file} other {# tâches remises en file}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Activité",
     "title": "Activité",
@@ -159,7 +160,18 @@
       "done": "Terminée",
       "done": "Terminée",
       "failed": "Échec",
       "failed": "Échec",
       "dead": "Abandonnée"
       "dead": "Abandonnée"
-    }
+    },
+    "jobs": "{count, plural, one {# tâche} other {# tâches}}",
+    "counts": {
+      "queued": "{count} en attente",
+      "running": "{count} en cours",
+      "done": "{count} terminées",
+      "failed": "{count} échouées",
+      "dead": "{count} abandonnées"
+    },
+    "noFailures": "Aucun échec.",
+    "retryFailed": "Réessayer les échecs",
+    "details": "Afficher les détails"
   },
   },
   "errors": {
   "errors": {
     "generic": "Une erreur s'est produite",
     "generic": "Une erreur s'est produite",

+ 10 - 1
messages/it/calendar.json

@@ -77,7 +77,16 @@
       "message": "Messaggio"
       "message": "Messaggio"
     },
     },
     "noEvents": "Nessun evento",
     "noEvents": "Nessun evento",
-    "selectDay": "Seleziona un giorno con eventi o crea un nuovo ordine di lavoro."
+    "selectDay": "Seleziona un giorno con eventi o crea un nuovo ordine di lavoro.",
+    "external": {
+      "source": "Da {source}",
+      "sourceUnknown": "Da un calendario collegato",
+      "allDay": "Tutto il giorno",
+      "open": "Apri in {source}",
+      "openGeneric": "Apri nel calendario",
+      "noLink": "Questo calendario non fornisce un link all'evento.",
+      "readOnly": "Tempo occupato da un calendario collegato. Modificalo lì e qui si aggiorna alla prossima sincronizzazione."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/it/integrations.json

@@ -147,7 +147,8 @@
       "off": "Attiva l'impostazione e salva; fino ad allora un passaggio non controlla nulla.",
       "off": "Attiva l'impostazione e salva; fino ad allora un passaggio non controlla nulla.",
       "syncNowHelp": "Sincronizza ora avvia subito un passaggio invece di attendere quello orario. Cerca 25 veicoli alla volta e prosegue da solo finché tutti i veicoli da controllare sono stati fatti."
       "syncNowHelp": "Sincronizza ora avvia subito un passaggio invece di attendere quello orario. Cerca 25 veicoli alla volta e prosegue da solo finché tutti i veicoli da controllare sono stati fatti."
     },
     },
-    "supersedes": "Collegare {next} scollega {current}. Solo uno può essere attivo alla volta, quindi {current} smette di essere usato appena {next} entra in servizio."
+    "supersedes": "Collegare {next} scollega {current}. Solo uno può essere attivo alla volta, quindi {current} smette di essere usato appena {next} entra in servizio.",
+    "batchRetried": "{count, plural, =0 {Niente da riprovare} one {# attività rimessa in coda} other {# attività rimesse in coda}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Attività",
     "title": "Attività",
@@ -159,7 +160,18 @@
       "done": "Completata",
       "done": "Completata",
       "failed": "Non riuscita",
       "failed": "Non riuscita",
       "dead": "Abbandonata"
       "dead": "Abbandonata"
-    }
+    },
+    "jobs": "{count, plural, one {# attività} other {# attività}}",
+    "counts": {
+      "queued": "{count} in coda",
+      "running": "{count} in corso",
+      "done": "{count} completate",
+      "failed": "{count} non riuscite",
+      "dead": "{count} abbandonate"
+    },
+    "noFailures": "Nessun errore.",
+    "retryFailed": "Riprova quelle non riuscite",
+    "details": "Mostra dettagli"
   },
   },
   "errors": {
   "errors": {
     "generic": "Qualcosa è andato storto",
     "generic": "Qualcosa è andato storto",

+ 10 - 1
messages/lt/calendar.json

@@ -77,7 +77,16 @@
       "message": "Žinutė"
       "message": "Žinutė"
     },
     },
     "noEvents": "Įvykių nėra",
     "noEvents": "Įvykių nėra",
-    "selectDay": "Pasirinkite dieną su įvykiais arba sukurkite naują darbo užsakymą."
+    "selectDay": "Pasirinkite dieną su įvykiais arba sukurkite naują darbo užsakymą.",
+    "external": {
+      "source": "Iš {source}",
+      "sourceUnknown": "Iš prijungto kalendoriaus",
+      "allDay": "Visą dieną",
+      "open": "Atidaryti „{source}“",
+      "openGeneric": "Atidaryti kalendoriuje",
+      "noLink": "Šis kalendorius nepateikia nuorodos į įvykį.",
+      "readOnly": "Užimtas laikas iš prijungto kalendoriaus. Keiskite jį ten, čia atsinaujins per kitą sinchronizaciją."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/lt/integrations.json

@@ -147,7 +147,8 @@
       "off": "Įjunkite nustatymą ir išsaugokite; iki tol perėjimas nieko netikrina.",
       "off": "Įjunkite nustatymą ir išsaugokite; iki tol perėjimas nieko netikrina.",
       "syncNowHelp": "„Sinchronizuoti dabar“ iš karto pradeda perėjimą, nelaukdamas valandinio. Tikrinama po 25 transporto priemones ir tęsiama savaime, kol apdorojamos visos, kurioms atėjo laikas."
       "syncNowHelp": "„Sinchronizuoti dabar“ iš karto pradeda perėjimą, nelaukdamas valandinio. Tikrinama po 25 transporto priemones ir tęsiama savaime, kol apdorojamos visos, kurioms atėjo laikas."
     },
     },
-    "supersedes": "Prijungus {next}, {current} bus atjungtas. Vienu metu gali veikti tik vienas, todėl {current} nustos būti naudojamas, kai tik {next} pradės veikti."
+    "supersedes": "Prijungus {next}, {current} bus atjungtas. Vienu metu gali veikti tik vienas, todėl {current} nustos būti naudojamas, kai tik {next} pradės veikti.",
+    "batchRetried": "{count, plural, =0 {Nėra ko kartoti} one {# užduotis vėl eilėje} few {# užduotys vėl eilėje} many {# užduoties vėl eilėje} other {# užduočių vėl eilėje}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Veikla",
     "title": "Veikla",
@@ -159,7 +160,18 @@
       "done": "Atlikta",
       "done": "Atlikta",
       "failed": "Nepavyko",
       "failed": "Nepavyko",
       "dead": "Nutraukta"
       "dead": "Nutraukta"
-    }
+    },
+    "jobs": "{count, plural, one {# užduotis} few {# užduotys} many {# užduoties} other {# užduočių}}",
+    "counts": {
+      "queued": "{count} eilėje",
+      "running": "{count} vykdoma",
+      "done": "{count} atlikta",
+      "failed": "{count} nepavyko",
+      "dead": "{count} atsisakyta"
+    },
+    "noFailures": "Niekas nenutrūko.",
+    "retryFailed": "Kartoti nepavykusias",
+    "details": "Rodyti detales"
   },
   },
   "errors": {
   "errors": {
     "generic": "Kažkas nepavyko",
     "generic": "Kažkas nepavyko",

+ 10 - 1
messages/nb/calendar.json

@@ -77,7 +77,16 @@
       "message": "Melding"
       "message": "Melding"
     },
     },
     "noEvents": "Ingen hendelser",
     "noEvents": "Ingen hendelser",
-    "selectDay": "Velg en dag med hendelser eller opprett en ny arbeidsordre."
+    "selectDay": "Velg en dag med hendelser eller opprett en ny arbeidsordre.",
+    "external": {
+      "source": "Fra {source}",
+      "sourceUnknown": "Fra en tilkoblet kalender",
+      "allDay": "Hele dagen",
+      "open": "Åpne i {source}",
+      "openGeneric": "Åpne i kalenderen",
+      "noLink": "Denne kalenderen gir ingen lenke til hendelsen.",
+      "readOnly": "Opptatt tid fra en tilkoblet kalender. Endre den der, så oppdateres den her ved neste synkronisering."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/nb/integrations.json

@@ -147,7 +147,8 @@
       "off": "Slå på innstillingen og lagre; før det sjekker en gjennomgang ingenting.",
       "off": "Slå på innstillingen og lagre; før det sjekker en gjennomgang ingenting.",
       "syncNowHelp": "Synkroniser nå starter en gjennomgang med en gang i stedet for å vente på den timevise. Den slår opp 25 kjøretøy om gangen og fortsetter av seg selv til alle med forfalt sjekk er ferdige."
       "syncNowHelp": "Synkroniser nå starter en gjennomgang med en gang i stedet for å vente på den timevise. Den slår opp 25 kjøretøy om gangen og fortsetter av seg selv til alle med forfalt sjekk er ferdige."
     },
     },
-    "supersedes": "Å koble til {next} kobler fra {current}. Bare én kan være i bruk om gangen, så {current} tas ut av bruk i det {next} settes i gang."
+    "supersedes": "Å koble til {next} kobler fra {current}. Bare én kan være i bruk om gangen, så {current} tas ut av bruk i det {next} settes i gang.",
+    "batchRetried": "{count, plural, =0 {Ingenting å prøve på nytt} one {# jobb lagt i kø igjen} other {# jobber lagt i kø igjen}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Aktivitet",
     "title": "Aktivitet",
@@ -159,7 +160,18 @@
       "done": "Ferdig",
       "done": "Ferdig",
       "failed": "Mislyktes",
       "failed": "Mislyktes",
       "dead": "Ga opp"
       "dead": "Ga opp"
-    }
+    },
+    "jobs": "{count, plural, one {# jobb} other {# jobber}}",
+    "counts": {
+      "queued": "{count} i kø",
+      "running": "{count} kjører",
+      "done": "{count} ferdig",
+      "failed": "{count} mislyktes",
+      "dead": "{count} ga opp"
+    },
+    "noFailures": "Ingenting mislyktes.",
+    "retryFailed": "Prøv mislykkede på nytt",
+    "details": "Vis detaljer"
   },
   },
   "errors": {
   "errors": {
     "generic": "Noe gikk galt",
     "generic": "Noe gikk galt",

+ 10 - 1
messages/nl/calendar.json

@@ -77,7 +77,16 @@
       "message": "Bericht"
       "message": "Bericht"
     },
     },
     "noEvents": "Geen evenementen",
     "noEvents": "Geen evenementen",
-    "selectDay": "Selecteer een dag met evenementen of maak een nieuwe werkorder aan."
+    "selectDay": "Selecteer een dag met evenementen of maak een nieuwe werkorder aan.",
+    "external": {
+      "source": "Uit {source}",
+      "sourceUnknown": "Uit een gekoppelde agenda",
+      "allDay": "Hele dag",
+      "open": "Openen in {source}",
+      "openGeneric": "Openen in agenda",
+      "noLink": "Deze agenda geeft geen link naar de afspraak.",
+      "readOnly": "Bezette tijd uit een gekoppelde agenda. Wijzig het daar, bij de volgende synchronisatie wordt het hier bijgewerkt."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/nl/integrations.json

@@ -147,7 +147,8 @@
       "off": "Zet de instelling aan en sla op; tot die tijd controleert een ronde niets.",
       "off": "Zet de instelling aan en sla op; tot die tijd controleert een ronde niets.",
       "syncNowHelp": "Nu synchroniseren start meteen een ronde in plaats van op de uurlijkse te wachten. Het zoekt 25 voertuigen tegelijk op en gaat vanzelf door tot alle voertuigen aan de beurt zijn geweest."
       "syncNowHelp": "Nu synchroniseren start meteen een ronde in plaats van op de uurlijkse te wachten. Het zoekt 25 voertuigen tegelijk op en gaat vanzelf door tot alle voertuigen aan de beurt zijn geweest."
     },
     },
-    "supersedes": "Als u {next} verbindt, wordt {current} losgekoppeld. Er kan er maar één tegelijk actief zijn, dus {current} wordt niet meer gebruikt zodra {next} live gaat."
+    "supersedes": "Als u {next} verbindt, wordt {current} losgekoppeld. Er kan er maar één tegelijk actief zijn, dus {current} wordt niet meer gebruikt zodra {next} live gaat.",
+    "batchRetried": "{count, plural, =0 {Niets om opnieuw te proberen} one {# taak opnieuw in de wachtrij} other {# taken opnieuw in de wachtrij}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Activiteit",
     "title": "Activiteit",
@@ -159,7 +160,18 @@
       "done": "Klaar",
       "done": "Klaar",
       "failed": "Mislukt",
       "failed": "Mislukt",
       "dead": "Opgegeven"
       "dead": "Opgegeven"
-    }
+    },
+    "jobs": "{count, plural, one {# taak} other {# taken}}",
+    "counts": {
+      "queued": "{count} in wachtrij",
+      "running": "{count} bezig",
+      "done": "{count} klaar",
+      "failed": "{count} mislukt",
+      "dead": "{count} opgegeven"
+    },
+    "noFailures": "Niets is mislukt.",
+    "retryFailed": "Mislukte opnieuw proberen",
+    "details": "Details tonen"
   },
   },
   "errors": {
   "errors": {
     "generic": "Er is iets misgegaan",
     "generic": "Er is iets misgegaan",

+ 10 - 1
messages/pl/calendar.json

@@ -77,7 +77,16 @@
       "message": "Wiadomość"
       "message": "Wiadomość"
     },
     },
     "noEvents": "Brak wydarzeń",
     "noEvents": "Brak wydarzeń",
-    "selectDay": "Wybierz dzień z wydarzeniami lub utwórz nowe zlecenie."
+    "selectDay": "Wybierz dzień z wydarzeniami lub utwórz nowe zlecenie.",
+    "external": {
+      "source": "Z {source}",
+      "sourceUnknown": "Z połączonego kalendarza",
+      "allDay": "Cały dzień",
+      "open": "Otwórz w {source}",
+      "openGeneric": "Otwórz w kalendarzu",
+      "noLink": "Ten kalendarz nie podaje linku do wydarzenia.",
+      "readOnly": "Zajęty czas z połączonego kalendarza. Zmień go tam, a tutaj zaktualizuje się przy następnej synchronizacji."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/pl/integrations.json

@@ -147,7 +147,8 @@
       "off": "Włącz ustawienie i zapisz; do tego czasu przebieg niczego nie sprawdza.",
       "off": "Włącz ustawienie i zapisz; do tego czasu przebieg niczego nie sprawdza.",
       "syncNowHelp": "Synchronizuj teraz uruchamia przebieg od razu zamiast czekać na cogodzinny. Sprawdza po 25 pojazdów i kontynuuje sam, aż wszystkie oczekujące pojazdy zostaną obsłużone."
       "syncNowHelp": "Synchronizuj teraz uruchamia przebieg od razu zamiast czekać na cogodzinny. Sprawdza po 25 pojazdów i kontynuuje sam, aż wszystkie oczekujące pojazdy zostaną obsłużone."
     },
     },
-    "supersedes": "Podłączenie {next} odłączy {current}. Naraz może działać tylko jedno, więc {current} przestanie być używane, gdy tylko {next} zacznie działać."
+    "supersedes": "Podłączenie {next} odłączy {current}. Naraz może działać tylko jedno, więc {current} przestanie być używane, gdy tylko {next} zacznie działać.",
+    "batchRetried": "{count, plural, =0 {Nie ma czego ponawiać} one {# zadanie ponownie w kolejce} few {# zadania ponownie w kolejce} many {# zadań ponownie w kolejce} other {# zadania ponownie w kolejce}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Aktywność",
     "title": "Aktywność",
@@ -159,7 +160,18 @@
       "done": "Gotowe",
       "done": "Gotowe",
       "failed": "Nieudane",
       "failed": "Nieudane",
       "dead": "Przerwano"
       "dead": "Przerwano"
-    }
+    },
+    "jobs": "{count, plural, one {# zadanie} few {# zadania} many {# zadań} other {# zadania}}",
+    "counts": {
+      "queued": "{count} w kolejce",
+      "running": "{count} w toku",
+      "done": "{count} gotowe",
+      "failed": "{count} nieudane",
+      "dead": "{count} porzucone"
+    },
+    "noFailures": "Nic nie zawiodło.",
+    "retryFailed": "Ponów nieudane",
+    "details": "Pokaż szczegóły"
   },
   },
   "errors": {
   "errors": {
     "generic": "Coś poszło nie tak",
     "generic": "Coś poszło nie tak",

+ 10 - 1
messages/pt-BR/calendar.json

@@ -77,7 +77,16 @@
       "message": "Mensagem"
       "message": "Mensagem"
     },
     },
     "noEvents": "Sem eventos",
     "noEvents": "Sem eventos",
-    "selectDay": "Selecione um dia com eventos ou crie uma nova ordem de serviço."
+    "selectDay": "Selecione um dia com eventos ou crie uma nova ordem de serviço.",
+    "external": {
+      "source": "De {source}",
+      "sourceUnknown": "De uma agenda conectada",
+      "allDay": "Dia inteiro",
+      "open": "Abrir no {source}",
+      "openGeneric": "Abrir na agenda",
+      "noLink": "Esta agenda não fornece um link para o evento.",
+      "readOnly": "Horário ocupado de uma agenda conectada. Altere lá e aqui atualiza na próxima sincronização."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/pt-BR/integrations.json

@@ -147,7 +147,8 @@
       "off": "Ative a opção e salve; até lá uma passagem não verifica nada.",
       "off": "Ative a opção e salve; até lá uma passagem não verifica nada.",
       "syncNowHelp": "Sincronizar agora inicia uma passagem imediatamente em vez de esperar a de hora em hora. Consulta 25 veículos por vez e continua sozinha até terminar todos os veículos pendentes."
       "syncNowHelp": "Sincronizar agora inicia uma passagem imediatamente em vez de esperar a de hora em hora. Consulta 25 veículos por vez e continua sozinha até terminar todos os veículos pendentes."
     },
     },
-    "supersedes": "Conectar a {next} desconecta a {current}. Só uma pode estar ativa por vez, então {current} deixa de ser usada assim que {next} entrar no ar."
+    "supersedes": "Conectar a {next} desconecta a {current}. Só uma pode estar ativa por vez, então {current} deixa de ser usada assim que {next} entrar no ar.",
+    "batchRetried": "{count, plural, =0 {Nada para tentar de novo} one {# tarefa de volta à fila} other {# tarefas de volta à fila}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Atividade",
     "title": "Atividade",
@@ -159,7 +160,18 @@
       "done": "Concluída",
       "done": "Concluída",
       "failed": "Falhou",
       "failed": "Falhou",
       "dead": "Desistiu"
       "dead": "Desistiu"
-    }
+    },
+    "jobs": "{count, plural, one {# tarefa} other {# tarefas}}",
+    "counts": {
+      "queued": "{count} na fila",
+      "running": "{count} em andamento",
+      "done": "{count} concluídas",
+      "failed": "{count} com falha",
+      "dead": "{count} desistidas"
+    },
+    "noFailures": "Nada falhou.",
+    "retryFailed": "Tentar as com falha de novo",
+    "details": "Mostrar detalhes"
   },
   },
   "errors": {
   "errors": {
     "generic": "Algo deu errado",
     "generic": "Algo deu errado",

+ 10 - 1
messages/ru/calendar.json

@@ -77,7 +77,16 @@
       "message": "Сообщение"
       "message": "Сообщение"
     },
     },
     "noEvents": "Нет событий",
     "noEvents": "Нет событий",
-    "selectDay": "Выберите день с событиями или создайте новый заказ-наряд."
+    "selectDay": "Выберите день с событиями или создайте новый заказ-наряд.",
+    "external": {
+      "source": "Из {source}",
+      "sourceUnknown": "Из подключённого календаря",
+      "allDay": "Весь день",
+      "open": "Открыть в {source}",
+      "openGeneric": "Открыть в календаре",
+      "noLink": "Этот календарь не даёт ссылку на событие.",
+      "readOnly": "Занятое время из подключённого календаря. Измените его там, и здесь оно обновится при следующей синхронизации."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/ru/integrations.json

@@ -147,7 +147,8 @@
       "off": "Включите настройку и сохраните; до этого проход ничего не проверяет.",
       "off": "Включите настройку и сохраните; до этого проход ничего не проверяет.",
       "syncNowHelp": "«Синхронизировать сейчас» запускает проход немедленно, не дожидаясь ежечасного. Он проверяет по 25 автомобилей и продолжает сам, пока не обработает все ожидающие."
       "syncNowHelp": "«Синхронизировать сейчас» запускает проход немедленно, не дожидаясь ежечасного. Он проверяет по 25 автомобилей и продолжает сам, пока не обработает все ожидающие."
     },
     },
-    "supersedes": "Подключение {next} отключит {current}. Одновременно работает только один, поэтому {current} перестанет использоваться, как только заработает {next}."
+    "supersedes": "Подключение {next} отключит {current}. Одновременно работает только один, поэтому {current} перестанет использоваться, как только заработает {next}.",
+    "batchRetried": "{count, plural, =0 {Нечего повторять} one {# задача снова в очереди} few {# задачи снова в очереди} many {# задач снова в очереди} other {# задачи снова в очереди}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Активность",
     "title": "Активность",
@@ -159,7 +160,18 @@
       "done": "Готово",
       "done": "Готово",
       "failed": "Ошибка",
       "failed": "Ошибка",
       "dead": "Прекращено"
       "dead": "Прекращено"
-    }
+    },
+    "jobs": "{count, plural, one {# задача} few {# задачи} many {# задач} other {# задачи}}",
+    "counts": {
+      "queued": "{count} в очереди",
+      "running": "{count} выполняется",
+      "done": "{count} готово",
+      "failed": "{count} с ошибкой",
+      "dead": "{count} прекращено"
+    },
+    "noFailures": "Ошибок нет.",
+    "retryFailed": "Повторить неудавшиеся",
+    "details": "Показать подробности"
   },
   },
   "errors": {
   "errors": {
     "generic": "Что-то пошло не так",
     "generic": "Что-то пошло не так",

+ 10 - 1
messages/tr/calendar.json

@@ -77,7 +77,16 @@
       "message": "Mesaj"
       "message": "Mesaj"
     },
     },
     "noEvents": "Etkinlik yok",
     "noEvents": "Etkinlik yok",
-    "selectDay": "Etkinliği olan bir gün seçin veya yeni bir iş emri oluşturun."
+    "selectDay": "Etkinliği olan bir gün seçin veya yeni bir iş emri oluşturun.",
+    "external": {
+      "source": "{source} kaynağından",
+      "sourceUnknown": "Bağlı bir takvimden",
+      "allDay": "Tüm gün",
+      "open": "{source} içinde aç",
+      "openGeneric": "Takvimde aç",
+      "noLink": "Bu takvim etkinliğe bağlantı vermiyor.",
+      "readOnly": "Bağlı bir takvimden gelen dolu zaman. Orada değiştirin, bir sonraki eşitlemede burada güncellenir."
+    }
   },
   },
   "previousMonth": "Previous month",
   "previousMonth": "Previous month",
   "nextMonth": "Next month"
   "nextMonth": "Next month"

+ 14 - 2
messages/tr/integrations.json

@@ -147,7 +147,8 @@
       "off": "Ayarı açıp kaydedin; o zamana kadar tarama hiçbir şeyi kontrol etmez.",
       "off": "Ayarı açıp kaydedin; o zamana kadar tarama hiçbir şeyi kontrol etmez.",
       "syncNowHelp": "Şimdi eşitle, saatlik taramayı beklemek yerine hemen bir tarama başlatır. Her seferinde 25 aracı sorgular ve sırası gelen tüm araçlar bitene kadar kendi kendine devam eder."
       "syncNowHelp": "Şimdi eşitle, saatlik taramayı beklemek yerine hemen bir tarama başlatır. Her seferinde 25 aracı sorgular ve sırası gelen tüm araçlar bitene kadar kendi kendine devam eder."
     },
     },
-    "supersedes": "{next} bağlandığında {current} bağlantısı kesilir. Aynı anda yalnızca biri geçerli olabilir; {next} devreye girdiği anda {current} kullanılmayı bırakır."
+    "supersedes": "{next} bağlandığında {current} bağlantısı kesilir. Aynı anda yalnızca biri geçerli olabilir; {next} devreye girdiği anda {current} kullanılmayı bırakır.",
+    "batchRetried": "{count, plural, =0 {Yeniden denenecek bir şey yok} one {# iş yeniden kuyruğa alındı} other {# iş yeniden kuyruğa alındı}}"
   },
   },
   "activity": {
   "activity": {
     "title": "Aktivite",
     "title": "Aktivite",
@@ -159,7 +160,18 @@
       "done": "Tamamlandı",
       "done": "Tamamlandı",
       "failed": "Başarısız",
       "failed": "Başarısız",
       "dead": "Vazgeçildi"
       "dead": "Vazgeçildi"
-    }
+    },
+    "jobs": "{count, plural, one {# iş} other {# iş}}",
+    "counts": {
+      "queued": "{count} kuyrukta",
+      "running": "{count} çalışıyor",
+      "done": "{count} tamamlandı",
+      "failed": "{count} başarısız",
+      "dead": "{count} vazgeçildi"
+    },
+    "noFailures": "Hiçbiri başarısız olmadı.",
+    "retryFailed": "Başarısızları yeniden dene",
+    "details": "Ayrıntıları göster"
   },
   },
   "errors": {
   "errors": {
     "generic": "Bir şeyler ters gitti",
     "generic": "Bir şeyler ters gitti",

+ 3 - 0
prisma/migrations/20260906120000_external_event_remote_url/migration.sql

@@ -0,0 +1,3 @@
+-- The pulled busy event's page in the vendor's own calendar, so the workshop
+-- calendar can open it there.
+ALTER TABLE "external_calendar_events" ADD COLUMN "remoteUrl" TEXT;

+ 2 - 0
prisma/schema/integrations.prisma

@@ -120,6 +120,8 @@ model ExternalCalendarEvent {
   startAt         DateTime
   startAt         DateTime
   endAt           DateTime
   endAt           DateTime
   allDay          Boolean               @default(false)
   allDay          Boolean               @default(false)
+  /// The event's page in the vendor's own calendar, when the vendor gives one.
+  remoteUrl       String?
   remoteUpdatedAt DateTime?
   remoteUpdatedAt DateTime?
   createdAt       DateTime              @default(now())
   createdAt       DateTime              @default(now())
   updatedAt       DateTime              @updatedAt
   updatedAt       DateTime              @updatedAt

+ 188 - 52
src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx

@@ -9,6 +9,7 @@ import {
   AlertTriangle,
   AlertTriangle,
   ArrowLeft,
   ArrowLeft,
   Check,
   Check,
+  ChevronDown,
   Copy,
   Copy,
   Loader2,
   Loader2,
   Mail,
   Mail,
@@ -44,6 +45,7 @@ import {
   type ConnectionView,
   type ConnectionView,
   disconnectIntegration,
   disconnectIntegration,
   getIntegrationRemoteOptions,
   getIntegrationRemoteOptions,
+  retryIntegrationBatch,
   retryIntegrationJob,
   retryIntegrationJob,
   runIntegrationJob,
   runIntegrationJob,
   saveIntegrationCredentials,
   saveIntegrationCredentials,
@@ -52,22 +54,15 @@ import {
   updateIntegrationSettings,
   updateIntegrationSettings,
 } from '@/features/integrations/Actions/integrationActions'
 } from '@/features/integrations/Actions/integrationActions'
 import type {
 import type {
+  ActivityItem,
+  ActivityJob,
   CredentialField,
   CredentialField,
   SettingField,
   SettingField,
   SettingOption,
   SettingOption,
 } from '@/features/integrations/Lib/types'
 } from '@/features/integrations/Lib/types'
 
 
 type Activity = {
 type Activity = {
-  jobs: {
-    id: string
-    kind: string
-    status: string
-    attempts: number
-    error: string | null
-    runAfter: string
-    finishedAt: string | null
-    createdAt: string
-  }[]
+  items: ActivityItem[]
   logs: { id: string; level: string; message: string; createdAt: string; details?: unknown }[]
   logs: { id: string; level: string; message: string; createdAt: string; details?: unknown }[]
 }
 }
 
 
@@ -97,6 +92,19 @@ export function ConnectionSettings({
     if (error || connected) router.replace(`/settings/integrations/${manifest.id}`)
     if (error || connected) router.replace(`/settings/integrations/${manifest.id}`)
   }, [search, router, t, manifest])
   }, [search, router, t, manifest])
 
 
+  // A backfill runs fifty jobs a minute in the background; while any are
+  // still pending the counts on the activity card follow along.
+  const pending = activity.items.some((i) =>
+    i.type === 'batch'
+      ? i.counts.queued + i.counts.running > 0
+      : i.status === 'queued' || i.status === 'running'
+  )
+  useEffect(() => {
+    if (!pending) return
+    const id = setInterval(() => router.refresh(), 10_000)
+    return () => clearInterval(id)
+  }, [pending, router])
+
   const isOAuth = manifest.auth.type === 'oauth2'
   const isOAuth = manifest.auth.type === 'oauth2'
   // A connected service with a required setting still empty is not working,
   // A connected service with a required setting still empty is not working,
   // whatever the vendor's status says; the calendar to write to is the usual
   // whatever the vendor's status says; the calendar to write to is the usual
@@ -581,12 +589,22 @@ export function ConnectionSettings({
         />
         />
       )}
       )}
 
 
-      {connection && (activity.jobs.length > 0 || activity.logs.length > 0) && (
+      {connection && (activity.items.length > 0 || activity.logs.length > 0) && (
         <ActivityCard
         <ActivityCard
           activity={activity}
           activity={activity}
           onRetry={(id) =>
           onRetry={(id) =>
             run(`retry:${id}`, () => retryIntegrationJob(id), t('connection.retryQueued'))
             run(`retry:${id}`, () => retryIntegrationJob(id), t('connection.retryQueued'))
           }
           }
+          onRetryBatch={(batchId) =>
+            run(
+              `retry-batch:${batchId}`,
+              () => retryIntegrationBatch(manifest.id, batchId),
+              (data) =>
+                t('connection.batchRetried', {
+                  count: (data as { retried?: number } | undefined)?.retried ?? 0,
+                })
+            )
+          }
           busy={busy}
           busy={busy}
         />
         />
       )}
       )}
@@ -963,57 +981,33 @@ function describeDetails(details: Record<string, unknown>): string {
 function ActivityCard({
 function ActivityCard({
   activity,
   activity,
   onRetry,
   onRetry,
+  onRetryBatch,
   busy,
   busy,
 }: {
 }: {
   activity: Activity
   activity: Activity
   onRetry: (jobId: string) => void
   onRetry: (jobId: string) => void
+  onRetryBatch: (batchId: string) => void
   busy: string | null
   busy: string | null
 }) {
 }) {
   const t = useTranslations('integrations.activity')
   const t = useTranslations('integrations.activity')
-  // Job kinds are shared by every connector, so their labels live at the
-  // namespace root rather than under the activity card.
-  const tk = useTranslations('integrations')
-  const format = useFormatter()
-  const when = (iso: string) =>
-    format.dateTime(new Date(iso), { dateStyle: 'short', timeStyle: 'short' })
+  const when = useWhen()
   return (
   return (
     <AppCard title={t('title')} description={t('description')}>
     <AppCard title={t('title')} description={t('description')}>
-      {activity.jobs.length > 0 && (
+      {activity.items.length > 0 && (
         <div className="mb-4 divide-y rounded-lg border">
         <div className="mb-4 divide-y rounded-lg border">
-          {activity.jobs.map((j) => (
-            <div
-              key={j.id}
-              className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm"
-            >
-              <div className="min-w-0">
-                <span className="font-medium">
-                  {tk.has(`jobKinds.${j.kind}`) ? tk(`jobKinds.${j.kind}`) : j.kind}
-                </span>
-                <span className="ml-2 text-xs text-muted-foreground">{when(j.createdAt)}</span>
-                {j.error && <p className="truncate text-xs text-destructive">{j.error}</p>}
-              </div>
-              <div className="flex items-center gap-2">
-                <Badge
-                  variant="outline"
-                  className={`text-[11px] ${j.status === 'dead' || j.status === 'failed' ? 'border-destructive/30 text-destructive' : j.status === 'done' ? 'border-emerald-500/30 text-emerald-600' : ''}`}
-                >
-                  {t(`jobStatus.${j.status}`)}
-                </Badge>
-                {(j.status === 'dead' || j.status === 'failed') && (
-                  <Button
-                    variant="ghost"
-                    size="sm"
-                    className="h-7 text-xs"
-                    onClick={() => onRetry(j.id)}
-                    disabled={busy !== null}
-                  >
-                    <Play className="mr-1 h-3 w-3" />
-                    {t('retry')}
-                  </Button>
-                )}
-              </div>
-            </div>
-          ))}
+          {activity.items.map((item) =>
+            item.type === 'batch' ? (
+              <BatchRow
+                key={item.batchId}
+                batch={item}
+                onRetry={onRetry}
+                onRetryBatch={onRetryBatch}
+                busy={busy}
+              />
+            ) : (
+              <JobRow key={item.id} job={item} onRetry={onRetry} busy={busy} />
+            )
+          )}
         </div>
         </div>
       )}
       )}
       {activity.logs.length > 0 && (
       {activity.logs.length > 0 && (
@@ -1043,3 +1037,145 @@ function ActivityCard({
     </AppCard>
     </AppCard>
   )
   )
 }
 }
+
+const FAILED = new Set(['failed', 'dead'])
+
+function statusClass(status: string): string {
+  if (FAILED.has(status)) return 'border-destructive/30 text-destructive'
+  if (status === 'done') return 'border-emerald-500/30 text-emerald-600'
+  return ''
+}
+
+/** Job kinds are shared by every connector, so their labels live at the namespace root. */
+function useJobKindLabel() {
+  const tk = useTranslations('integrations')
+  return (kind: string) => (tk.has(`jobKinds.${kind}`) ? tk(`jobKinds.${kind}`) : kind)
+}
+
+function useWhen() {
+  const format = useFormatter()
+  return (iso: string) => format.dateTime(new Date(iso), { dateStyle: 'short', timeStyle: 'short' })
+}
+
+function JobRow({
+  job,
+  onRetry,
+  busy,
+}: {
+  job: ActivityJob
+  onRetry: (jobId: string) => void
+  busy: string | null
+}) {
+  const t = useTranslations('integrations.activity')
+  const kindLabel = useJobKindLabel()
+  const when = useWhen()
+  return (
+    <div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm">
+      <div className="min-w-0">
+        <span className="font-medium">{kindLabel(job.kind)}</span>
+        <span className="ml-2 text-xs text-muted-foreground">{when(job.createdAt)}</span>
+        {job.error && <p className="truncate text-xs text-destructive">{job.error}</p>}
+      </div>
+      <div className="flex items-center gap-2">
+        <Badge variant="outline" className={`text-[11px] ${statusClass(job.status)}`}>
+          {t(`jobStatus.${job.status}`)}
+        </Badge>
+        {FAILED.has(job.status) && (
+          <Button
+            variant="ghost"
+            size="sm"
+            className="h-7 text-xs"
+            onClick={() => onRetry(job.id)}
+            disabled={busy !== null}
+          >
+            <Play className="mr-1 h-3 w-3" />
+            {t('retry')}
+          </Button>
+        )}
+      </div>
+    </div>
+  )
+}
+
+const COUNT_ORDER = ['queued', 'running', 'done', 'failed', 'dead'] as const
+
+/**
+ * A backfill as one line: the kind, how many jobs, and a badge per status
+ * that is not zero. Opening it lists the failed jobs, each with its own
+ * Retry, and one button to queue them all again.
+ */
+function BatchRow({
+  batch,
+  onRetry,
+  onRetryBatch,
+  busy,
+}: {
+  batch: Extract<ActivityItem, { type: 'batch' }>
+  onRetry: (jobId: string) => void
+  onRetryBatch: (batchId: string) => void
+  busy: string | null
+}) {
+  const t = useTranslations('integrations.activity')
+  const kindLabel = useJobKindLabel()
+  const when = useWhen()
+  const [open, setOpen] = useState(false)
+  const total = COUNT_ORDER.reduce((sum, k) => sum + batch.counts[k], 0)
+  const failedCount = batch.counts.failed + batch.counts.dead
+  return (
+    <div>
+      <button
+        type="button"
+        onClick={() => setOpen((o) => !o)}
+        aria-expanded={open}
+        aria-label={t('details')}
+        className="flex w-full flex-wrap items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-muted/40"
+      >
+        <div className="min-w-0">
+          <span className="font-medium">{kindLabel(batch.kind)}</span>
+          <span className="ml-2 text-xs text-muted-foreground">
+            {t('jobs', { count: total })} · {when(batch.createdAt)}
+          </span>
+        </div>
+        <div className="flex flex-wrap items-center gap-1.5">
+          {COUNT_ORDER.filter((k) => batch.counts[k] > 0).map((k) => (
+            <Badge key={k} variant="outline" className={`text-[11px] ${statusClass(k)}`}>
+              {t(`counts.${k}`, { count: batch.counts[k] })}
+            </Badge>
+          ))}
+          <ChevronDown
+            className={`h-4 w-4 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
+          />
+        </div>
+      </button>
+      {open && (
+        <div className="border-t bg-muted/30">
+          {batch.failures.length === 0 ? (
+            <p className="px-3 py-2 text-xs text-muted-foreground">{t('noFailures')}</p>
+          ) : (
+            <>
+              {failedCount > 1 && (
+                <div className="flex justify-end px-3 pt-2">
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    className="h-7 text-xs"
+                    onClick={() => onRetryBatch(batch.batchId)}
+                    disabled={busy !== null}
+                  >
+                    <Play className="mr-1 h-3 w-3" />
+                    {t('retryFailed')}
+                  </Button>
+                </div>
+              )}
+              <div className="divide-y">
+                {batch.failures.map((job) => (
+                  <JobRow key={job.id} job={job} onRetry={onRetry} busy={busy} />
+                ))}
+              </div>
+            </>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}

+ 1 - 1
src/app/(authenticated)/settings/integrations/[connector]/page.tsx

@@ -43,7 +43,7 @@ export default async function IntegrationConnectionPage({
   return (
   return (
     <ConnectionSettings
     <ConnectionSettings
       view={view.data}
       view={view.data}
-      activity={activity.success && activity.data ? activity.data : { jobs: [], logs: [] }}
+      activity={activity.success && activity.data ? activity.data : { items: [], logs: [] }}
     />
     />
   )
   )
 }
 }

+ 20 - 1
src/features/calendar/Actions/calendarActions.ts

@@ -3,6 +3,7 @@
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { getManifest } from '@/integrations/registry'
 
 
 export type CalendarEvent = {
 export type CalendarEvent = {
   id: string
   id: string
@@ -18,6 +19,11 @@ export type CalendarEvent = {
   customerName: string | null
   customerName: string | null
   invoiceNumber: string | null
   invoiceNumber: string | null
   amount: number | null
   amount: number | null
+  /** Only on external events: when it ends, whether it fills the day, where it came from. */
+  endTime?: string | null
+  allDay?: boolean
+  source?: string | null
+  externalUrl?: string | null
 }
 }
 
 
 /** Format a Date as YYYY-MM-DD in local time (avoids UTC shift from toISOString) */
 /** Format a Date as YYYY-MM-DD in local time (avoids UTC shift from toISOString) */
@@ -139,7 +145,15 @@ export async function getCalendarEvents(params: { start: string; end: string })
         // Busy time pulled from connected calendars; read-only on this side.
         // Busy time pulled from connected calendars; read-only on this side.
         db.externalCalendarEvent.findMany({
         db.externalCalendarEvent.findMany({
           where: { organizationId, startAt: { lte: end }, endAt: { gte: start } },
           where: { organizationId, startAt: { lte: end }, endAt: { gte: start } },
-          select: { id: true, title: true, startAt: true, endAt: true, allDay: true },
+          select: {
+            id: true,
+            title: true,
+            startAt: true,
+            endAt: true,
+            allDay: true,
+            remoteUrl: true,
+            connection: { select: { connectorId: true } },
+          },
           orderBy: { startAt: 'asc' },
           orderBy: { startAt: 'asc' },
         }),
         }),
       ])
       ])
@@ -150,6 +164,7 @@ export async function getCalendarEvents(params: { start: string; end: string })
         // does not flood the month.
         // does not flood the month.
         const first = new Date(e.startAt)
         const first = new Date(e.startAt)
         const last = new Date(e.endAt.getTime() - 1)
         const last = new Date(e.endAt.getTime() - 1)
+        const source = getManifest(e.connection.connectorId)?.name ?? null
         for (let d = new Date(first), n = 0; d <= last && n < 31; d.setDate(d.getDate() + 1), n++) {
         for (let d = new Date(first), n = 0; d <= last && n < 31; d.setDate(d.getDate() + 1), n++) {
           if (d < start || d > end) continue
           if (d < start || d > end) continue
           externalEvents.push({
           externalEvents.push({
@@ -164,6 +179,10 @@ export async function getCalendarEvents(params: { start: string; end: string })
             customerName: null,
             customerName: null,
             invoiceNumber: null,
             invoiceNumber: null,
             amount: null,
             amount: null,
+            endTime: e.allDay ? null : toTimeStr(e.endAt),
+            allDay: e.allDay,
+            source,
+            externalUrl: e.remoteUrl,
           })
           })
         }
         }
       }
       }

+ 10 - 1
src/features/calendar/Components/CalendarDayCell.tsx

@@ -203,7 +203,16 @@ export function CalendarDayCell({
                 {dayEvents.slice(0, MAX_MENU_EVENTS).map((event) => (
                 {dayEvents.slice(0, MAX_MENU_EVENTS).map((event) => (
                   <ContextMenuItem
                   <ContextMenuItem
                     key={`${event.type}-${event.id}`}
                     key={`${event.type}-${event.id}`}
-                    onClick={() => router.push(getEventLink(event))}
+                    onClick={() => {
+                      // Busy time from another calendar opens there, or
+                      // just selects the day so the list can show it.
+                      if (event.type === 'external') {
+                        if (event.externalUrl) window.open(event.externalUrl, '_blank', 'noopener')
+                        else onClick()
+                        return
+                      }
+                      router.push(getEventLink(event))
+                    }}
                   >
                   >
                     <div
                     <div
                       className={`mr-2 h-1.5 w-1.5 shrink-0 rounded-full ${getEventDotColor(event)}`}
                       className={`mr-2 h-1.5 w-1.5 shrink-0 rounded-full ${getEventDotColor(event)}`}

+ 139 - 38
src/features/calendar/Components/CalendarEventList.tsx

@@ -1,8 +1,19 @@
 'use client'
 'use client'
 
 
 import Link from 'next/link'
 import Link from 'next/link'
-import { useTranslations } from 'next-intl'
+import { useState } from 'react'
+import { useFormatter, useTranslations } from 'next-intl'
+import { CalendarClock, ExternalLink } from 'lucide-react'
 import { Badge } from '@/components/ui/badge'
 import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
 import { formatDateHeader, getEventLink } from './calendar-utils'
 import { formatDateHeader, getEventLink } from './calendar-utils'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import type { CalendarEvent } from '../Actions/calendarActions'
 import type { CalendarEvent } from '../Actions/calendarActions'
@@ -232,6 +243,54 @@ export function CalendarEventList({
   const formatCurrency = useFormatCurrency()
   const formatCurrency = useFormatCurrency()
   const t = useTranslations('calendar')
   const t = useTranslations('calendar')
   const dayEvents = events.filter((e) => e.date === dateStr)
   const dayEvents = events.filter((e) => e.date === dateStr)
+  // Busy time from a connected calendar has no page of its own here, so a
+  // click opens it in place instead of sending the reader to settings.
+  const [external, setExternal] = useState<CalendarEvent | null>(null)
+
+  const rowClass =
+    'flex w-full items-start gap-2.5 rounded-lg border p-2.5 text-left transition-colors hover:bg-muted/50'
+  const body = (event: CalendarEvent) => (
+    <>
+      <div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${getStatusColor(event)}`} />
+      <div className="min-w-0 flex-1">
+        <div className="flex items-center gap-1.5 flex-wrap">
+          <p className="text-sm font-medium truncate">{event.title}</p>
+          {getStatusBadge(event, t)}
+        </div>
+        <div className="flex items-center gap-2 mt-0.5">
+          <p className="text-xs text-muted-foreground truncate">
+            {event.type === 'external' && event.source
+              ? t('events.external.source', { source: event.source })
+              : event.vehicleLabel}
+          </p>
+          {event.time && (
+            <span className="text-xs text-muted-foreground shrink-0">
+              {event.endTime ? `${event.time} – ${event.endTime}` : event.time}
+            </span>
+          )}
+          {event.type === 'external' && event.allDay && (
+            <span className="text-xs text-muted-foreground shrink-0">
+              {t('events.external.allDay')}
+            </span>
+          )}
+        </div>
+        {event.customerName && (
+          <p className="text-xs text-muted-foreground">{event.customerName}</p>
+        )}
+        <div className="flex items-center gap-2 mt-1">
+          {getTypeBadge(event.type, t)}
+          {event.invoiceNumber && (
+            <span className="text-[10px] text-muted-foreground">#{event.invoiceNumber}</span>
+          )}
+          {event.amount != null && (
+            <span className="text-[10px] font-medium ml-auto">
+              {formatCurrency(event.amount, currencyCode)}
+            </span>
+          )}
+        </div>
+      </div>
+    </>
+  )
 
 
   return (
   return (
     <div className="space-y-3">
     <div className="space-y-3">
@@ -244,45 +303,87 @@ export function CalendarEventList({
         </div>
         </div>
       ) : (
       ) : (
         <div className="space-y-2">
         <div className="space-y-2">
-          {dayEvents.map((event) => (
-            <Link
-              key={`${event.type}-${event.id}`}
-              href={getEventLink(event)}
-              className="flex items-start gap-2.5 rounded-lg border p-2.5 transition-colors hover:bg-muted/50"
-            >
-              <div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${getStatusColor(event)}`} />
-              <div className="min-w-0 flex-1">
-                <div className="flex items-center gap-1.5 flex-wrap">
-                  <p className="text-sm font-medium truncate">{event.title}</p>
-                  {getStatusBadge(event, t)}
-                </div>
-                <div className="flex items-center gap-2 mt-0.5">
-                  <p className="text-xs text-muted-foreground truncate">{event.vehicleLabel}</p>
-                  {event.time && (
-                    <span className="text-xs text-muted-foreground shrink-0">{event.time}</span>
-                  )}
-                </div>
-                {event.customerName && (
-                  <p className="text-xs text-muted-foreground">{event.customerName}</p>
-                )}
-                <div className="flex items-center gap-2 mt-1">
-                  {getTypeBadge(event.type, t)}
-                  {event.invoiceNumber && (
-                    <span className="text-[10px] text-muted-foreground">
-                      #{event.invoiceNumber}
-                    </span>
-                  )}
-                  {event.amount != null && (
-                    <span className="text-[10px] font-medium ml-auto">
-                      {formatCurrency(event.amount, currencyCode)}
-                    </span>
-                  )}
-                </div>
-              </div>
-            </Link>
-          ))}
+          {dayEvents.map((event) =>
+            event.type === 'external' ? (
+              <button
+                key={`${event.type}-${event.id}`}
+                type="button"
+                onClick={() => setExternal(event)}
+                className={rowClass}
+              >
+                {body(event)}
+              </button>
+            ) : (
+              <Link
+                key={`${event.type}-${event.id}`}
+                href={getEventLink(event)}
+                className={rowClass}
+              >
+                {body(event)}
+              </Link>
+            )
+          )}
         </div>
         </div>
       )}
       )}
+      <ExternalEventDialog event={external} onClose={() => setExternal(null)} />
     </div>
     </div>
   )
   )
 }
 }
+
+/**
+ * What a busy block from Google or Outlook is: title, when, which calendar,
+ * and a way to open it there. Nothing here edits it; the vendor owns it.
+ */
+function ExternalEventDialog({
+  event,
+  onClose,
+}: {
+  event: CalendarEvent | null
+  onClose: () => void
+}) {
+  const t = useTranslations('calendar.events.external')
+  const format = useFormatter()
+  const day = event ? new Date(`${event.date}T00:00:00`) : null
+  return (
+    <Dialog open={event !== null} onOpenChange={(open) => !open && onClose()}>
+      <DialogContent className="sm:max-w-md">
+        {event && day && (
+          <>
+            <DialogHeader>
+              <DialogTitle className="break-words">{event.title}</DialogTitle>
+              <DialogDescription>
+                {event.source ? t('source', { source: event.source }) : t('sourceUnknown')}
+              </DialogDescription>
+            </DialogHeader>
+            <div className="flex items-start gap-3 text-sm">
+              <CalendarClock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+              <div>
+                <p>{format.dateTime(day, { dateStyle: 'full' })}</p>
+                <p className="text-muted-foreground">
+                  {event.allDay || !event.time
+                    ? t('allDay')
+                    : event.endTime
+                      ? `${event.time} – ${event.endTime}`
+                      : event.time}
+                </p>
+              </div>
+            </div>
+            <p className="text-xs text-muted-foreground">{t('readOnly')}</p>
+            <DialogFooter>
+              {event.externalUrl ? (
+                <Button asChild>
+                  <a href={event.externalUrl} target="_blank" rel="noopener noreferrer">
+                    <ExternalLink className="mr-2 h-4 w-4" />
+                    {event.source ? t('open', { source: event.source }) : t('openGeneric')}
+                  </a>
+                </Button>
+              ) : (
+                <p className="text-xs text-muted-foreground">{t('noLink')}</p>
+              )}
+            </DialogFooter>
+          </>
+        )}
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 5 - 1
src/features/calendar/Components/calendar-utils.ts

@@ -6,7 +6,11 @@ export function toLocalDateStr(d: Date): string {
   return `${y}-${m}-${day}`
   return `${y}-${m}-${day}`
 }
 }
 
 
-/** Where an event points: the quote itself, otherwise the vehicle it belongs to */
+/**
+ * Where an event points: the quote itself, otherwise the vehicle it belongs
+ * to. External events have no page here; they open a dialog, or the vendor's
+ * calendar, and only land on the integrations list as a last resort.
+ */
 export function getEventLink(event: {
 export function getEventLink(event: {
   id: string
   id: string
   type: 'service' | 'reminder' | 'quote' | 'message' | 'external'
   type: 'service' | 'reminder' | 'quote' | 'message' | 'external'

+ 168 - 26
src/features/integrations/Actions/integrationActions.ts

@@ -1,5 +1,6 @@
 'use server'
 'use server'
 
 
+import { randomUUID } from 'node:crypto'
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
 import { z } from 'zod'
 import { z } from 'zod'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
@@ -26,7 +27,14 @@ import {
 } from '../Lib/connections'
 } from '../Lib/connections'
 import { enqueueJob, runJob } from '../Lib/jobs'
 import { enqueueJob, runJob } from '../Lib/jobs'
 import { oauthSpec, platformClient, redirectUriFor } from '../Lib/oauth'
 import { oauthSpec, platformClient, redirectUriFor } from '../Lib/oauth'
-import type { ConnectionStatus, ConnectorManifest, SettingOption } from '../Lib/types'
+import type {
+  ActivityBatch,
+  ActivityItem,
+  ActivityJob,
+  ConnectionStatus,
+  ConnectorManifest,
+  SettingOption,
+} from '../Lib/types'
 import { messagingProvider, providersForChannel } from '@/integrations/messaging/catalog'
 import { messagingProvider, providersForChannel } from '@/integrations/messaging/catalog'
 import {
 import {
   type InboundWebhook,
   type InboundWebhook,
@@ -639,12 +647,15 @@ export async function backfillIntegrationCalendar(connectorId: string) {
         select: { id: true },
         select: { id: true },
         take: 500,
         take: 500,
       })
       })
+      // One batch id across the run, so the activity list shows it as one
+      // row with counts instead of a page of identical jobs.
+      const batchId = randomUUID()
       for (const r of records) {
       for (const r of records) {
         await enqueueJob({
         await enqueueJob({
           connectionId: row.id,
           connectionId: row.id,
           organizationId,
           organizationId,
           kind: 'calendar.push',
           kind: 'calendar.push',
-          payload: { entityId: r.id, event: 'backfill' },
+          payload: { entityId: r.id, event: 'backfill', batchId },
           idempotencyKey: `calendar.push:${r.id}`,
           idempotencyKey: `calendar.push:${r.id}`,
         })
         })
       }
       }
@@ -678,13 +689,14 @@ export async function backfillIntegrationAccounting(connectorId: string) {
           ? new Date(new Date(`${startDate}T00:00:00Z`).getTime() - 86_400_000)
           ? new Date(new Date(`${startDate}T00:00:00Z`).getTime() - 86_400_000)
           : null
           : null
       const ids = await invoicesForBackfill(organizationId, since)
       const ids = await invoicesForBackfill(organizationId, since)
+      const batchId = randomUUID()
       if (ids.length > 0) {
       if (ids.length > 0) {
         await db.integrationJob.createMany({
         await db.integrationJob.createMany({
           data: ids.map((id) => ({
           data: ids.map((id) => ({
             connectionId: row.id,
             connectionId: row.id,
             organizationId,
             organizationId,
             kind: 'accounting.invoice',
             kind: 'accounting.invoice',
-            payload: { entityId: id, event: 'backfill' },
+            payload: { entityId: id, event: 'backfill', batchId },
             idempotencyKey: `accounting.invoice:${id}`,
             idempotencyKey: `accounting.invoice:${id}`,
           })),
           })),
           skipDuplicates: true,
           skipDuplicates: true,
@@ -743,6 +755,56 @@ export async function disconnectIntegration(connectorId: string) {
   )
   )
 }
 }
 
 
+const JOB_SELECT = {
+  id: true,
+  kind: true,
+  status: true,
+  attempts: true,
+  error: true,
+  runAfter: true,
+  finishedAt: true,
+  createdAt: true,
+} as const
+
+type JobRow = {
+  id: string
+  kind: string
+  status: string
+  attempts: number
+  error: string | null
+  runAfter: Date
+  finishedAt: Date | null
+  createdAt: Date
+}
+
+function toActivityJob(j: JobRow): ActivityJob {
+  return {
+    ...j,
+    runAfter: j.runAfter.toISOString(),
+    finishedAt: j.finishedAt?.toISOString() ?? null,
+    createdAt: j.createdAt.toISOString(),
+  }
+}
+
+/** A batch's jobs grouped as one line, single jobs as their own. */
+interface JobGroup {
+  groupKey: string
+  batchId: string | null
+  kind: string
+  createdAt: Date
+  queued: number
+  running: number
+  done: number
+  failed: number
+  dead: number
+}
+
+/**
+ * The last twenty things that happened on a connection, where a backfill
+ * counts as one thing however many jobs it queued. Each batch carries its
+ * counts by status and the jobs that failed, since those are the ones with
+ * a Retry button.
+ */
 export async function getIntegrationActivity(connectorId: string) {
 export async function getIntegrationActivity(connectorId: string) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
@@ -750,23 +812,23 @@ export async function getIntegrationActivity(connectorId: string) {
         where: { organizationId_connectorId: { organizationId, connectorId } },
         where: { organizationId_connectorId: { organizationId, connectorId } },
         select: { id: true },
         select: { id: true },
       })
       })
-      if (!row) return { jobs: [], logs: [] }
-      const [jobs, logs] = await Promise.all([
-        db.integrationJob.findMany({
-          where: { connectionId: row.id },
-          orderBy: { createdAt: 'desc' },
-          take: 20,
-          select: {
-            id: true,
-            kind: true,
-            status: true,
-            attempts: true,
-            error: true,
-            runAfter: true,
-            finishedAt: true,
-            createdAt: true,
-          },
-        }),
+      if (!row) return { items: [] as ActivityItem[], logs: [] }
+      const [groups, logs] = await Promise.all([
+        db.$queryRaw<JobGroup[]>`
+          SELECT coalesce(payload->>'batchId', id) AS "groupKey",
+                 payload->>'batchId' AS "batchId",
+                 kind,
+                 min("createdAt") AS "createdAt",
+                 count(*) FILTER (WHERE status = 'queued')::int AS queued,
+                 count(*) FILTER (WHERE status = 'running')::int AS running,
+                 count(*) FILTER (WHERE status = 'done')::int AS done,
+                 count(*) FILTER (WHERE status = 'failed')::int AS failed,
+                 count(*) FILTER (WHERE status = 'dead')::int AS dead
+          FROM integration_jobs
+          WHERE "connectionId" = ${row.id}
+          GROUP BY 1, 2, 3
+          ORDER BY 4 DESC
+          LIMIT 20`,
         db.integrationLog.findMany({
         db.integrationLog.findMany({
           where: { connectionId: row.id },
           where: { connectionId: row.id },
           orderBy: { createdAt: 'desc' },
           orderBy: { createdAt: 'desc' },
@@ -774,13 +836,60 @@ export async function getIntegrationActivity(connectorId: string) {
           select: { id: true, level: true, message: true, details: true, createdAt: true },
           select: { id: true, level: true, message: true, details: true, createdAt: true },
         }),
         }),
       ])
       ])
+      const singleIds = groups.filter((g) => !g.batchId).map((g) => g.groupKey)
+      const batchIds = groups.flatMap((g) => (g.batchId ? [g.batchId] : []))
+      const [singles, failures] = await Promise.all([
+        singleIds.length > 0
+          ? db.integrationJob.findMany({ where: { id: { in: singleIds } }, select: JOB_SELECT })
+          : [],
+        batchIds.length > 0
+          ? db.integrationJob.findMany({
+              where: {
+                connectionId: row.id,
+                status: { in: ['failed', 'dead'] },
+                OR: batchIds.map((batchId) => ({
+                  payload: { path: ['batchId'], equals: batchId },
+                })),
+              },
+              orderBy: { createdAt: 'desc' },
+              take: 200,
+              select: { ...JOB_SELECT, payload: true },
+            })
+          : [],
+      ])
+      const singleById = new Map(singles.map((j) => [j.id, j]))
+      const failuresByBatch = new Map<string, ActivityJob[]>()
+      for (const f of failures) {
+        const batchId = (f.payload as { batchId?: unknown } | null)?.batchId
+        if (typeof batchId !== 'string') continue
+        const list = failuresByBatch.get(batchId) ?? []
+        list.push(toActivityJob(f))
+        failuresByBatch.set(batchId, list)
+      }
+      const items: ActivityItem[] = []
+      for (const g of groups) {
+        if (g.batchId) {
+          const batch: ActivityBatch = {
+            batchId: g.batchId,
+            kind: g.kind,
+            createdAt: g.createdAt.toISOString(),
+            counts: {
+              queued: g.queued,
+              running: g.running,
+              done: g.done,
+              failed: g.failed,
+              dead: g.dead,
+            },
+            failures: failuresByBatch.get(g.batchId) ?? [],
+          }
+          items.push({ type: 'batch', ...batch })
+        } else {
+          const job = singleById.get(g.groupKey)
+          if (job) items.push({ type: 'job', ...toActivityJob(job) })
+        }
+      }
       return {
       return {
-        jobs: jobs.map((j) => ({
-          ...j,
-          runAfter: j.runAfter.toISOString(),
-          finishedAt: j.finishedAt?.toISOString() ?? null,
-          createdAt: j.createdAt.toISOString(),
-        })),
+        items,
         logs: logs.map((l) => ({ ...l, createdAt: l.createdAt.toISOString() })),
         logs: logs.map((l) => ({ ...l, createdAt: l.createdAt.toISOString() })),
       }
       }
     },
     },
@@ -807,6 +916,39 @@ export async function retryIntegrationJob(jobId: string) {
   )
   )
 }
 }
 
 
+const batchIdSchema = z.string().uuid()
+
+/** Queue every failed job of one backfill again, in a single write. */
+export async function retryIntegrationBatch(connectorId: string, rawBatchId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const batchId = batchIdSchema.parse(rawBatchId)
+      const row = await db.integrationConnection.findUnique({
+        where: { organizationId_connectorId: { organizationId, connectorId } },
+        select: { id: true },
+      })
+      if (!row) throw new Error('Unknown integration')
+      const r = await db.integrationJob.updateMany({
+        where: {
+          connectionId: row.id,
+          organizationId,
+          status: { in: ['dead', 'failed'] },
+          payload: { path: ['batchId'], equals: batchId },
+        },
+        data: {
+          status: 'queued',
+          runAfter: new Date(),
+          attempts: 0,
+          error: null,
+          finishedAt: null,
+        },
+      })
+      return { retried: r.count }
+    },
+    { requiredPermissions: SETTINGS_PERMISSION }
+  )
+}
+
 const SERVICE_PERMISSION = [
 const SERVICE_PERMISSION = [
   { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
   { action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES },
 ]
 ]

+ 3 - 0
src/features/integrations/Lib/calendar-sync.ts

@@ -130,6 +130,8 @@ export interface PulledEvent {
   end: Date
   end: Date
   allDay: boolean
   allDay: boolean
   updatedAt: Date | null
   updatedAt: Date | null
+  /** Where the event opens in the vendor's calendar; null when not offered. */
+  url: string | null
 }
 }
 
 
 export function pullWindow(now = new Date()): { from: Date; to: Date } {
 export function pullWindow(now = new Date()): { from: Date; to: Date } {
@@ -176,6 +178,7 @@ export async function storePulledEvents(
           startAt: e.start,
           startAt: e.start,
           endAt: e.end,
           endAt: e.end,
           allDay: e.allDay,
           allDay: e.allDay,
+          remoteUrl: e.url,
           remoteUpdatedAt: e.updatedAt,
           remoteUpdatedAt: e.updatedAt,
         })),
         })),
         skipDuplicates: true,
         skipDuplicates: true,

+ 23 - 0
src/features/integrations/Lib/types.ts

@@ -391,3 +391,26 @@ export interface OAuthCredentials {
   /** PKCE verifier and nonce, only while pending. */
   /** PKCE verifier and nonce, only while pending. */
   codeVerifier?: string
   codeVerifier?: string
 }
 }
+
+/** One job on the activity list. */
+export interface ActivityJob {
+  id: string
+  kind: string
+  status: string
+  attempts: number
+  error: string | null
+  runAfter: string
+  finishedAt: string | null
+  createdAt: string
+}
+
+/** A backfill on the activity list: its jobs by status, and the failed ones. */
+export interface ActivityBatch {
+  batchId: string
+  kind: string
+  createdAt: string
+  counts: { queued: number; running: number; done: number; failed: number; dead: number }
+  failures: ActivityJob[]
+}
+
+export type ActivityItem = ({ type: 'job' } & ActivityJob) | ({ type: 'batch' } & ActivityBatch)

+ 3 - 1
src/integrations/google-calendar/server.ts

@@ -22,6 +22,7 @@ interface GoogleEvent {
   start?: { dateTime?: string; date?: string }
   start?: { dateTime?: string; date?: string }
   end?: { dateTime?: string; date?: string }
   end?: { dateTime?: string; date?: string }
   updated?: string
   updated?: string
+  htmlLink?: string
   hangoutLink?: string
   hangoutLink?: string
   extendedProperties?: { private?: Record<string, string> }
   extendedProperties?: { private?: Record<string, string> }
 }
 }
@@ -141,6 +142,7 @@ function toPulled(e: GoogleEvent): PulledEvent | null {
     end: new Date(end),
     end: new Date(end),
     allDay,
     allDay,
     updatedAt: e.updated ? new Date(e.updated) : null,
     updatedAt: e.updated ? new Date(e.updated) : null,
+    url: e.htmlLink ?? null,
   }
   }
 }
 }
 
 
@@ -160,7 +162,7 @@ async function pullBusy(ctx: ConnectorContext) {
     url.searchParams.set('maxResults', '250')
     url.searchParams.set('maxResults', '250')
     url.searchParams.set(
     url.searchParams.set(
       'fields',
       'fields',
-      'nextPageToken,items(id,status,summary,start,end,updated,extendedProperties)'
+      'nextPageToken,items(id,status,summary,start,end,updated,htmlLink,extendedProperties)'
     )
     )
     if (pageToken) url.searchParams.set('pageToken', pageToken)
     if (pageToken) url.searchParams.set('pageToken', pageToken)
     const page = await ctx.http.json<{ items?: GoogleEvent[]; nextPageToken?: string }>(
     const page = await ctx.http.json<{ items?: GoogleEvent[]; nextPageToken?: string }>(

+ 2 - 1
src/integrations/microsoft-365/server.ts

@@ -149,6 +149,7 @@ function toPulled(e: GraphEvent): PulledEvent | null {
     end,
     end,
     allDay: Boolean(e.isAllDay),
     allDay: Boolean(e.isAllDay),
     updatedAt: e.lastModifiedDateTime ? new Date(e.lastModifiedDateTime) : null,
     updatedAt: e.lastModifiedDateTime ? new Date(e.lastModifiedDateTime) : null,
+    url: e.webLink ?? null,
   }
   }
 }
 }
 
 
@@ -167,7 +168,7 @@ async function pullBusy(ctx: ConnectorContext) {
     url.searchParams.set('$top', '250')
     url.searchParams.set('$top', '250')
     url.searchParams.set(
     url.searchParams.set(
       '$select',
       '$select',
-      'id,subject,start,end,isAllDay,isCancelled,lastModifiedDateTime'
+      'id,subject,start,end,isAllDay,isCancelled,lastModifiedDateTime,webLink'
     )
     )
     return url.toString()
     return url.toString()
   })()
   })()