Procházet zdrojové kódy

Calendar rebuilt as a full-screen planner, reminders with a time, and dates read in the workshop's timezone (#339)

* Rebuild the calendar as a full-screen planner: day, week, 4-day, month, year and schedule views, in-place event peek, click-to-create on the time grid, and workshop-timezone dates

* Cover the time grid's click-to-create menu, peek and right-click menu with tests

* Pin the calendar to the viewport so the time grid scrolls inside it instead of the page

* Snap grid clicks to the half hour

* Store a scheduled message's send time in the workshop's timezone, not the server's

* Reminders can carry a time of day, stored in the workshop's zone; right-click an event on the calendar to open it, tick off or delete a reminder, or delete a scheduled message

* Shared workshop-zone date helpers: day ranges, day ends, wall-clock stepping and month keys

* Read every date the browser sends in the workshop's timezone, not the server's: board bookings, service and invoice dates, quotes, payments, portal requests, status report expiry, imports, report windows and buckets, recurring invoices, report schedules, scheduled message repeats, board week, portal dates, connected-calendar all-day events; quarterly report schedules step three months

* Fix the reminder migration's table name

* Leave the reminder time migration to prisma migrate dev

* Refuse to schedule a message for a time that has passed, and do not offer it for past slots

* Keep the schedule-message option in the menus; the dialog refuses a past time with a note, and the server refuses it too

* lint
Bernt Christian Egeland před 2 měsíci
rodič
revize
11de4bd5cf
100 změnil soubory, kde provedl 5585 přidání a 1639 odebrání
  1. 60 4
      messages/de/calendar.json
  2. 1 0
      messages/de/scheduledMessages.json
  3. 1 0
      messages/de/vehicles.json
  4. 60 4
      messages/en/calendar.json
  5. 1 0
      messages/en/scheduledMessages.json
  6. 1 0
      messages/en/vehicles.json
  7. 60 4
      messages/es/calendar.json
  8. 1 0
      messages/es/scheduledMessages.json
  9. 1 0
      messages/es/vehicles.json
  10. 60 4
      messages/fr/calendar.json
  11. 1 0
      messages/fr/scheduledMessages.json
  12. 1 0
      messages/fr/vehicles.json
  13. 60 4
      messages/it/calendar.json
  14. 1 0
      messages/it/scheduledMessages.json
  15. 1 0
      messages/it/vehicles.json
  16. 60 4
      messages/lt/calendar.json
  17. 1 0
      messages/lt/scheduledMessages.json
  18. 1 0
      messages/lt/vehicles.json
  19. 60 4
      messages/nb/calendar.json
  20. 1 0
      messages/nb/scheduledMessages.json
  21. 1 0
      messages/nb/vehicles.json
  22. 60 4
      messages/nl/calendar.json
  23. 1 0
      messages/nl/scheduledMessages.json
  24. 1 0
      messages/nl/vehicles.json
  25. 60 4
      messages/pl/calendar.json
  26. 1 0
      messages/pl/scheduledMessages.json
  27. 1 0
      messages/pl/vehicles.json
  28. 60 4
      messages/pt-BR/calendar.json
  29. 1 0
      messages/pt-BR/scheduledMessages.json
  30. 1 0
      messages/pt-BR/vehicles.json
  31. 60 4
      messages/ru/calendar.json
  32. 1 0
      messages/ru/scheduledMessages.json
  33. 1 0
      messages/ru/vehicles.json
  34. 60 4
      messages/tr/calendar.json
  35. 1 0
      messages/tr/scheduledMessages.json
  36. 1 0
      messages/tr/vehicles.json
  37. 2 0
      prisma/migrations/20260906161411_reminder_due_time/migration.sql
  38. 3 0
      prisma/schema/vehicles.prisma
  39. 213 0
      src/__tests__/features/calendar/calendar-range.test.ts
  40. 262 0
      src/__tests__/features/calendar/time-grid-interactions.test.tsx
  41. 109 109
      src/__tests__/features/integrations/fixtures/nhtsa/complaints-accord-2003.json
  42. 0 0
      src/__tests__/features/integrations/fixtures/nhtsa/decode-1HGCM82633A004352.json
  43. 175 175
      src/__tests__/features/integrations/fixtures/nhtsa/models-honda-2003.json
  44. 37 1
      src/__tests__/features/integrations/fixtures/nhtsa/rating-4738.json
  45. 10 1
      src/__tests__/features/integrations/fixtures/nhtsa/ratings-accord-2003.json
  46. 52 52
      src/__tests__/features/integrations/fixtures/nhtsa/recalls-accord-2003.json
  47. 86 0
      src/__tests__/features/invoices/recurring-invoice-timezone.test.ts
  48. 148 0
      src/__tests__/features/messaging/scheduled-message-timezone.test.ts
  49. 3 0
      src/__tests__/features/reminder-alerts.test.ts
  50. 140 0
      src/__tests__/features/reports/report-schedule-timezone.test.ts
  51. 2 0
      src/__tests__/features/reports/reports-tax.test.ts
  52. 4 0
      src/__tests__/features/workorders/create-workorder.test.ts
  53. 2 2
      src/__tests__/lib/auth-origin-hint.test.ts
  54. 100 0
      src/__tests__/lib/workshop-datetime.test.ts
  55. 54 24
      src/app/(authenticated)/calendar/page.tsx
  56. 10 6
      src/app/(authenticated)/vehicles/[id]/service/new/page.tsx
  57. 8 2
      src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx
  58. 10 10
      src/app/(authenticated)/work-board/page.tsx
  59. 11 2
      src/app/(presenter)/work-board/presenter/page.tsx
  60. 14 3
      src/app/(public)/portal/[orgId]/dashboard/page.tsx
  61. 9 2
      src/app/(public)/portal/[orgId]/inspections/page.tsx
  62. 13 2
      src/app/(public)/portal/[orgId]/invoices/page.tsx
  63. 10 2
      src/app/(public)/portal/[orgId]/quotes/page.tsx
  64. 7 2
      src/app/(public)/portal/[orgId]/request-service/page.tsx
  65. 11 3
      src/app/(public)/portal/[orgId]/vehicles/[vehicleId]/page.tsx
  66. 6 3
      src/app/api/protected/backup/import-invoice-ninja/route.ts
  67. 5 2
      src/app/api/protected/backup/import-lubelog/route.ts
  68. 15 2
      src/app/api/protected/backup/import/route.ts
  69. 3 0
      src/app/api/v1/tech/jobs/[id]/notes/route.ts
  70. 17 28
      src/features/billing/Actions/recurringInvoiceActions.ts
  71. 117 69
      src/features/calendar/Actions/calendarActions.ts
  72. 452 336
      src/features/calendar/Components/CalendarClient.tsx
  73. 142 237
      src/features/calendar/Components/CalendarDayCell.tsx
  74. 26 368
      src/features/calendar/Components/CalendarEventList.tsx
  75. 129 0
      src/features/calendar/Components/CalendarSidebar.tsx
  76. 200 0
      src/features/calendar/Components/CalendarToolbar.tsx
  77. 134 0
      src/features/calendar/Components/DayContextMenu.tsx
  78. 162 0
      src/features/calendar/Components/EventChip.tsx
  79. 121 0
      src/features/calendar/Components/EventContextMenu.tsx
  80. 273 0
      src/features/calendar/Components/EventPeek.tsx
  81. 73 0
      src/features/calendar/Components/ExternalEventDialog.tsx
  82. 114 0
      src/features/calendar/Components/MonthView.tsx
  83. 79 0
      src/features/calendar/Components/ScheduleView.tsx
  84. 482 0
      src/features/calendar/Components/TimeGridView.tsx
  85. 107 0
      src/features/calendar/Components/YearView.tsx
  86. 160 13
      src/features/calendar/Components/calendar-utils.ts
  87. 59 0
      src/features/calendar/Components/useCalendarPreferences.ts
  88. 24 0
      src/features/calendar/Components/useDayFormatter.ts
  89. 258 0
      src/features/calendar/Lib/calendar-range.ts
  90. 23 15
      src/features/dashboard/custom-cards/server-registry.ts
  91. 9 5
      src/features/inspections/Actions/inspectionActions.ts
  92. 3 2
      src/features/payments/Actions/paymentActions.ts
  93. 4 2
      src/features/portal/Actions/portalActions.ts
  94. 8 3
      src/features/quotes/Actions/quoteActions.ts
  95. 26 19
      src/features/report-schedule/Actions/reportScheduleActions.ts
  96. 48 61
      src/features/reports/Actions/reportActions.ts
  97. 28 14
      src/features/scheduled-messages/Actions/scheduledMessageActions.ts
  98. 12 4
      src/features/scheduled-messages/Components/ScheduleMessageDialog.tsx
  99. 11 8
      src/features/scheduled-messages/Lib/dispatchScheduledMessage.ts
  100. 6 2
      src/features/status-reports/Actions/createStatusReport.ts

+ 60 - 4
messages/de/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "Heute",
   "workOrder": "Arbeitsauftrag",
+  "allDay": "Ganztägig",
+  "openDay": "{date} öffnen",
+  "dayTotal": "{count, plural, =1 {1 Termin} other {# Termine}}",
+  "views": {
+    "day": "Tag",
+    "week": "Woche",
+    "month": "Monat",
+    "year": "Jahr",
+    "schedule": "Terminübersicht",
+    "fourDays": "4 Tage"
+  },
+  "options": {
+    "showWeekends": "Wochenenden anzeigen",
+    "showCompleted": "Abgeschlossene anzeigen",
+    "showWeekNumbers": "Kalenderwochen anzeigen"
+  },
+  "toolbar": {
+    "previous": "Zurück",
+    "next": "Weiter",
+    "create": "Erstellen",
+    "showSidebar": "Seitenleiste anzeigen",
+    "hideSidebar": "Seitenleiste ausblenden"
+  },
+  "sidebar": {
+    "calendars": "Kalender",
+    "legend": "Statusfarben"
+  },
+  "schedule": {
+    "empty": "Nichts geplant",
+    "emptyHint": "Keine Arbeitsaufträge, Erinnerungen, Angebote oder Nachrichten in diesem Zeitraum."
+  },
+  "peek": {
+    "close": "Schließen",
+    "open": {
+      "service": "Arbeitsauftrag öffnen",
+      "reminder": "Erinnerung öffnen",
+      "quote": "Angebot öffnen",
+      "message": "Nachrichten öffnen",
+      "external": "Öffnen"
+    }
+  },
   "filters": {
-    "services": "Aufträge",
+    "services": "Arbeitsaufträge",
     "reminders": "Erinnerungen",
     "quotes": "Angebote",
     "messages": "Nachrichten",
@@ -45,21 +86,35 @@
     "useToday": "Use today"
   },
   "more": "+ {count} weitere",
-  "monthTotal": "{count} Termine in diesem Monat",
+  "monthTotal": "{count} Termine",
   "contextMenu": {
     "newWorkOrder": "Neuer Arbeitsauftrag",
+    "newWorkOrderAt": "Neuer Arbeitsauftrag um {time}",
     "newReminder": "Neue Erinnerung",
     "newQuote": "Neues Angebot",
     "openEvent": "Öffnen ({count})",
     "hint": "Tipp: Rechtsklick auf einen Tag zeigt Aktionen",
     "scheduleMessage": "Nachricht planen"
   },
+  "eventMenu": {
+    "open": "Öffnen",
+    "markDone": "Als erledigt markieren",
+    "markNotDone": "Als nicht erledigt markieren",
+    "deleteReminder": "Erinnerung löschen",
+    "deleteReminderConfirm": "Erinnerung \"{title}\" löschen? Das lässt sich nicht rückgängig machen.",
+    "deleteMessage": "Geplante Nachricht löschen",
+    "deleteMessageConfirm": "Geplante Nachricht \"{title}\" löschen? Sie wird nicht gesendet.",
+    "deleted": "Gelöscht",
+    "updated": "Aktualisiert",
+    "failed": "Das hat nicht geklappt. Bitte erneut versuchen."
+  },
   "events": {
     "status": {
       "completed": "Abgeschlossen",
       "inProgress": "In Bearbeitung",
       "waitingParts": "Warte auf Teile",
       "pending": "Ausstehend",
+      "scheduled": "Geplant",
       "sent": "Gesendet",
       "approved": "Genehmigt",
       "draft": "Entwurf",
@@ -71,10 +126,11 @@
       "busy": "Belegt"
     },
     "type": {
-      "service": "Service",
+      "service": "Arbeitsauftrag",
       "reminder": "Erinnerung",
       "quote": "Angebot",
-      "message": "Nachricht"
+      "message": "Nachricht",
+      "external": "Verbundener Kalender"
     },
     "noEvents": "Keine Ereignisse",
     "selectDay": "Wählen Sie einen Tag mit Ereignissen oder erstellen Sie einen neuen Arbeitsauftrag.",

+ 1 - 0
messages/de/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Nachricht geplant",
     "updated": "Geplante Nachricht aktualisiert",
     "saveError": "Nachricht konnte nicht gespeichert werden",
+    "inPast": "Dieser Zeitpunkt liegt bereits in der Vergangenheit. Wähle eine Zeit in der Zukunft.",
     "whatsappNeedsTemplate": "Diese Nachricht geht lange nach Ablauf des 24-Stunden-Fensters raus, und es ist keine WhatsApp-Textvorlage eingerichtet, daher würde sie beim Senden scheitern. Richten Sie eine unter Einstellungen > Anbieter > WhatsApp ein."
   },
   "list": {

+ 1 - 0
messages/de/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Beschreibung",
     "descriptionPlaceholder": "Optionale Details...",
     "dueDateLabel": "Fälligkeitsdatum",
+    "dueTimeLabel": "Uhrzeit (optional)",
     "dueMileageLabel": "Fälligkeitskilometerstand",
     "dueMileagePlaceholder": "z.B. 50000",
     "reminderUpdated": "Erinnerung aktualisiert",

+ 60 - 4
messages/en/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "Today",
   "workOrder": "Work Order",
+  "allDay": "All day",
+  "openDay": "Open {date}",
+  "dayTotal": "{count, plural, =1 {1 event} other {# events}}",
+  "views": {
+    "day": "Day",
+    "week": "Week",
+    "month": "Month",
+    "year": "Year",
+    "schedule": "Schedule",
+    "fourDays": "4 days"
+  },
+  "options": {
+    "showWeekends": "Show weekends",
+    "showCompleted": "Show completed",
+    "showWeekNumbers": "Show week numbers"
+  },
+  "toolbar": {
+    "previous": "Previous",
+    "next": "Next",
+    "create": "Create",
+    "showSidebar": "Show side panel",
+    "hideSidebar": "Hide side panel"
+  },
+  "sidebar": {
+    "calendars": "Calendars",
+    "legend": "Status colours"
+  },
+  "schedule": {
+    "empty": "Nothing scheduled",
+    "emptyHint": "No work orders, reminders, quotes or messages in this period."
+  },
+  "peek": {
+    "close": "Close",
+    "open": {
+      "service": "Open work order",
+      "reminder": "Open reminder",
+      "quote": "Open quote",
+      "message": "Open messages",
+      "external": "Open"
+    }
+  },
   "filters": {
-    "services": "Services",
+    "services": "Work orders",
     "reminders": "Reminders",
     "quotes": "Quotes",
     "messages": "Messages",
@@ -45,21 +86,35 @@
     "useToday": "Use today"
   },
   "more": "+ {count} more",
-  "monthTotal": "{count} events this month",
+  "monthTotal": "{count} events",
   "contextMenu": {
     "newWorkOrder": "New work order",
+    "newWorkOrderAt": "New work order at {time}",
     "newReminder": "New reminder",
     "newQuote": "New quote",
     "openEvent": "Open ({count})",
     "hint": "Tip: right-click a day for actions",
     "scheduleMessage": "Schedule message"
   },
+  "eventMenu": {
+    "open": "Open",
+    "markDone": "Mark as done",
+    "markNotDone": "Mark as not done",
+    "deleteReminder": "Delete reminder",
+    "deleteReminderConfirm": "Delete the reminder \"{title}\"? This cannot be undone.",
+    "deleteMessage": "Delete scheduled message",
+    "deleteMessageConfirm": "Delete the scheduled message \"{title}\"? It will not be sent.",
+    "deleted": "Deleted",
+    "updated": "Updated",
+    "failed": "That did not work. Try again."
+  },
   "events": {
     "status": {
       "completed": "Completed",
       "inProgress": "In Progress",
       "waitingParts": "Waiting Parts",
       "pending": "Pending",
+      "scheduled": "Scheduled",
       "sent": "Sent",
       "approved": "Approved",
       "draft": "Draft",
@@ -71,10 +126,11 @@
       "busy": "Busy"
     },
     "type": {
-      "service": "Service",
+      "service": "Work order",
       "reminder": "Reminder",
       "quote": "Quote",
-      "message": "Message"
+      "message": "Message",
+      "external": "Connected calendar"
     },
     "noEvents": "No events",
     "selectDay": "Select a day with events or create a new work order.",

+ 1 - 0
messages/en/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Message scheduled",
     "updated": "Scheduled message updated",
     "saveError": "Could not save the message",
+    "inPast": "That time has already passed. Pick a time in the future.",
     "whatsappNeedsTemplate": "This will be sent long after the customer's 24 hour window closes, and no WhatsApp text template is set up, so it would fail at send time. Set one up in Settings > Providers > WhatsApp."
   },
   "list": {

+ 1 - 0
messages/en/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Description",
     "descriptionPlaceholder": "Optional details...",
     "dueDateLabel": "Due Date",
+    "dueTimeLabel": "Time (optional)",
     "dueMileageLabel": "Due Mileage",
     "dueMileagePlaceholder": "e.g. 50000",
     "reminderUpdated": "Reminder updated",

+ 60 - 4
messages/es/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "Hoy",
   "workOrder": "Orden de trabajo",
+  "allDay": "Todo el día",
+  "openDay": "Abrir {date}",
+  "dayTotal": "{count, plural, =1 {1 evento} other {# eventos}}",
+  "views": {
+    "day": "Día",
+    "week": "Semana",
+    "month": "Mes",
+    "year": "Año",
+    "schedule": "Agenda",
+    "fourDays": "4 días"
+  },
+  "options": {
+    "showWeekends": "Mostrar fines de semana",
+    "showCompleted": "Mostrar completados",
+    "showWeekNumbers": "Mostrar números de semana"
+  },
+  "toolbar": {
+    "previous": "Anterior",
+    "next": "Siguiente",
+    "create": "Crear",
+    "showSidebar": "Mostrar panel lateral",
+    "hideSidebar": "Ocultar panel lateral"
+  },
+  "sidebar": {
+    "calendars": "Calendarios",
+    "legend": "Colores de estado"
+  },
+  "schedule": {
+    "empty": "Nada programado",
+    "emptyHint": "No hay órdenes de trabajo, recordatorios, presupuestos ni mensajes en este periodo."
+  },
+  "peek": {
+    "close": "Cerrar",
+    "open": {
+      "service": "Abrir orden de trabajo",
+      "reminder": "Abrir recordatorio",
+      "quote": "Abrir presupuesto",
+      "message": "Abrir mensajes",
+      "external": "Abrir"
+    }
+  },
   "filters": {
-    "services": "Servicios",
+    "services": "Órdenes de trabajo",
     "reminders": "Recordatorios",
     "quotes": "Presupuestos",
     "messages": "Mensajes",
@@ -45,21 +86,35 @@
     "useToday": "Use today"
   },
   "more": "+ {count} más",
-  "monthTotal": "{count} eventos este mes",
+  "monthTotal": "{count} eventos",
   "contextMenu": {
     "newWorkOrder": "Nueva orden de trabajo",
+    "newWorkOrderAt": "Nueva orden de trabajo a las {time}",
     "newReminder": "Nuevo recordatorio",
     "newQuote": "Nuevo presupuesto",
     "openEvent": "Abrir ({count})",
     "hint": "Consejo: haz clic derecho en un día para ver las acciones",
     "scheduleMessage": "Programar mensaje"
   },
+  "eventMenu": {
+    "open": "Abrir",
+    "markDone": "Marcar como hecho",
+    "markNotDone": "Marcar como pendiente",
+    "deleteReminder": "Eliminar recordatorio",
+    "deleteReminderConfirm": "¿Eliminar el recordatorio \"{title}\"? Esta acción no se puede deshacer.",
+    "deleteMessage": "Eliminar mensaje programado",
+    "deleteMessageConfirm": "¿Eliminar el mensaje programado \"{title}\"? No se enviará.",
+    "deleted": "Eliminado",
+    "updated": "Actualizado",
+    "failed": "No se pudo completar. Inténtalo de nuevo."
+  },
   "events": {
     "status": {
       "completed": "Completado",
       "inProgress": "En progreso",
       "waitingParts": "Esperando piezas",
       "pending": "Pendiente",
+      "scheduled": "Programado",
       "sent": "Enviado",
       "approved": "Aprobado",
       "draft": "Borrador",
@@ -71,10 +126,11 @@
       "busy": "Ocupado"
     },
     "type": {
-      "service": "Servicio",
+      "service": "Orden de trabajo",
       "reminder": "Recordatorio",
       "quote": "Presupuesto",
-      "message": "Mensaje"
+      "message": "Mensaje",
+      "external": "Calendario conectado"
     },
     "noEvents": "Sin eventos",
     "selectDay": "Seleccione un día con eventos o cree una nueva orden de trabajo.",

+ 1 - 0
messages/es/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Mensaje programado",
     "updated": "Mensaje programado actualizado",
     "saveError": "No se pudo guardar el mensaje",
+    "inPast": "Esa hora ya ha pasado. Elige una hora futura.",
     "whatsappNeedsTemplate": "Se enviará mucho después de que se cierre la ventana de 24 horas del cliente y no hay plantilla de texto de WhatsApp configurada, así que fallaría al enviarse. Configura una en Ajustes > Proveedores > WhatsApp."
   },
   "list": {

+ 1 - 0
messages/es/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Descripción",
     "descriptionPlaceholder": "Detalles opcionales...",
     "dueDateLabel": "Fecha de vencimiento",
+    "dueTimeLabel": "Hora (opcional)",
     "dueMileageLabel": "Kilometraje de vencimiento",
     "dueMileagePlaceholder": "ej. 50000",
     "reminderUpdated": "Recordatorio actualizado",

+ 60 - 4
messages/fr/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "Aujourd’hui",
   "workOrder": "Ordre de travail",
+  "allDay": "Toute la journée",
+  "openDay": "Ouvrir le {date}",
+  "dayTotal": "{count, plural, =1 {1 événement} other {# événements}}",
+  "views": {
+    "day": "Jour",
+    "week": "Semaine",
+    "month": "Mois",
+    "year": "Année",
+    "schedule": "Planning",
+    "fourDays": "4 jours"
+  },
+  "options": {
+    "showWeekends": "Afficher les week-ends",
+    "showCompleted": "Afficher les terminés",
+    "showWeekNumbers": "Afficher les numéros de semaine"
+  },
+  "toolbar": {
+    "previous": "Précédent",
+    "next": "Suivant",
+    "create": "Créer",
+    "showSidebar": "Afficher le panneau latéral",
+    "hideSidebar": "Masquer le panneau latéral"
+  },
+  "sidebar": {
+    "calendars": "Calendriers",
+    "legend": "Couleurs des statuts"
+  },
+  "schedule": {
+    "empty": "Rien de prévu",
+    "emptyHint": "Aucun ordre de travail, rappel, devis ni message sur cette période."
+  },
+  "peek": {
+    "close": "Fermer",
+    "open": {
+      "service": "Ouvrir l'ordre de travail",
+      "reminder": "Ouvrir le rappel",
+      "quote": "Ouvrir le devis",
+      "message": "Ouvrir les messages",
+      "external": "Ouvrir"
+    }
+  },
   "filters": {
-    "services": "Services",
+    "services": "Ordres de travail",
     "reminders": "Rappels",
     "quotes": "Devis",
     "messages": "Messages",
@@ -45,21 +86,35 @@
     "useToday": "Use today"
   },
   "more": "+ {count} de plus",
-  "monthTotal": "{count} événements ce mois-ci",
+  "monthTotal": "{count} événements",
   "contextMenu": {
     "newWorkOrder": "Nouvel ordre de travail",
+    "newWorkOrderAt": "Nouvel ordre de travail à {time}",
     "newReminder": "Nouveau rappel",
     "newQuote": "Nouveau devis",
     "openEvent": "Ouvrir ({count})",
     "hint": "Astuce : faites un clic droit sur un jour pour voir les actions",
     "scheduleMessage": "Programmer un message"
   },
+  "eventMenu": {
+    "open": "Ouvrir",
+    "markDone": "Marquer comme fait",
+    "markNotDone": "Marquer comme non fait",
+    "deleteReminder": "Supprimer le rappel",
+    "deleteReminderConfirm": "Supprimer le rappel \"{title}\" ? Cette action est irréversible.",
+    "deleteMessage": "Supprimer le message programmé",
+    "deleteMessageConfirm": "Supprimer le message programmé \"{title}\" ? Il ne sera pas envoyé.",
+    "deleted": "Supprimé",
+    "updated": "Mis à jour",
+    "failed": "Cela n'a pas fonctionné. Réessayez."
+  },
   "events": {
     "status": {
       "completed": "Terminé",
       "inProgress": "En cours",
       "waitingParts": "En attente de pièces",
       "pending": "En attente",
+      "scheduled": "Planifié",
       "sent": "Envoyé",
       "approved": "Approuvé",
       "draft": "Brouillon",
@@ -71,10 +126,11 @@
       "busy": "Occupé"
     },
     "type": {
-      "service": "Service",
+      "service": "Ordre de travail",
       "reminder": "Rappel",
       "quote": "Devis",
-      "message": "Message"
+      "message": "Message",
+      "external": "Calendrier connecté"
     },
     "noEvents": "Aucun événement",
     "selectDay": "Sélectionnez un jour avec des événements ou créez un nouvel ordre de travail.",

+ 1 - 0
messages/fr/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Message programmé",
     "updated": "Message programmé mis à jour",
     "saveError": "Impossible d'enregistrer le message",
+    "inPast": "Cette heure est déjà passée. Choisissez un moment à venir.",
     "whatsappNeedsTemplate": "Ce message partira bien après la fermeture de la fenêtre de 24 heures et aucun modèle texte WhatsApp n'est configuré : il échouerait à l'envoi. Configurez-en un dans Paramètres > Fournisseurs > WhatsApp."
   },
   "list": {

+ 1 - 0
messages/fr/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Description",
     "descriptionPlaceholder": "Détails optionnels...",
     "dueDateLabel": "Date d'échéance",
+    "dueTimeLabel": "Heure (facultatif)",
     "dueMileageLabel": "Kilométrage d'échéance",
     "dueMileagePlaceholder": "ex. 50000",
     "reminderUpdated": "Rappel mis à jour",

+ 60 - 4
messages/it/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "Oggi",
   "workOrder": "Ordine di lavoro",
+  "allDay": "Tutto il giorno",
+  "openDay": "Apri {date}",
+  "dayTotal": "{count, plural, =1 {1 evento} other {# eventi}}",
+  "views": {
+    "day": "Giorno",
+    "week": "Settimana",
+    "month": "Mese",
+    "year": "Anno",
+    "schedule": "Agenda",
+    "fourDays": "4 giorni"
+  },
+  "options": {
+    "showWeekends": "Mostra weekend",
+    "showCompleted": "Mostra completati",
+    "showWeekNumbers": "Mostra numeri delle settimane"
+  },
+  "toolbar": {
+    "previous": "Precedente",
+    "next": "Successivo",
+    "create": "Crea",
+    "showSidebar": "Mostra pannello laterale",
+    "hideSidebar": "Nascondi pannello laterale"
+  },
+  "sidebar": {
+    "calendars": "Calendari",
+    "legend": "Colori di stato"
+  },
+  "schedule": {
+    "empty": "Nulla in programma",
+    "emptyHint": "Nessun ordine di lavoro, promemoria, preventivo o messaggio in questo periodo."
+  },
+  "peek": {
+    "close": "Chiudi",
+    "open": {
+      "service": "Apri ordine di lavoro",
+      "reminder": "Apri promemoria",
+      "quote": "Apri preventivo",
+      "message": "Apri messaggi",
+      "external": "Apri"
+    }
+  },
   "filters": {
-    "services": "Servizi",
+    "services": "Ordini di lavoro",
     "reminders": "Promemoria",
     "quotes": "Preventivi",
     "messages": "Messaggi",
@@ -45,21 +86,35 @@
     "useToday": "Use today"
   },
   "more": "+ {count} altri",
-  "monthTotal": "{count} eventi questo mese",
+  "monthTotal": "{count} eventi",
   "contextMenu": {
     "newWorkOrder": "Nuovo ordine di lavoro",
+    "newWorkOrderAt": "Nuovo ordine di lavoro alle {time}",
     "newReminder": "Nuovo promemoria",
     "newQuote": "Nuovo preventivo",
     "openEvent": "Apri ({count})",
     "hint": "Suggerimento: fai clic con il tasto destro su un giorno per le azioni",
     "scheduleMessage": "Pianifica messaggio"
   },
+  "eventMenu": {
+    "open": "Apri",
+    "markDone": "Segna come fatto",
+    "markNotDone": "Segna come non fatto",
+    "deleteReminder": "Elimina promemoria",
+    "deleteReminderConfirm": "Eliminare il promemoria \"{title}\"? L'operazione non può essere annullata.",
+    "deleteMessage": "Elimina messaggio pianificato",
+    "deleteMessageConfirm": "Eliminare il messaggio pianificato \"{title}\"? Non verrà inviato.",
+    "deleted": "Eliminato",
+    "updated": "Aggiornato",
+    "failed": "Operazione non riuscita. Riprova."
+  },
   "events": {
     "status": {
       "completed": "Completato",
       "inProgress": "In corso",
       "waitingParts": "In attesa di ricambi",
       "pending": "In attesa",
+      "scheduled": "Pianificato",
       "sent": "Inviato",
       "approved": "Approvato",
       "draft": "Bozza",
@@ -71,10 +126,11 @@
       "busy": "Occupato"
     },
     "type": {
-      "service": "Servizio",
+      "service": "Ordine di lavoro",
       "reminder": "Promemoria",
       "quote": "Preventivo",
-      "message": "Messaggio"
+      "message": "Messaggio",
+      "external": "Calendario collegato"
     },
     "noEvents": "Nessun evento",
     "selectDay": "Seleziona un giorno con eventi o crea un nuovo ordine di lavoro.",

+ 1 - 0
messages/it/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Messaggio pianificato",
     "updated": "Messaggio pianificato aggiornato",
     "saveError": "Impossibile salvare il messaggio",
+    "inPast": "Quell'ora è già passata. Scegli un orario futuro.",
     "whatsappNeedsTemplate": "Partirà molto dopo la chiusura della finestra di 24 ore e non c'è un modello di testo WhatsApp configurato, quindi fallirebbe all'invio. Configuralo in Impostazioni > Provider > WhatsApp."
   },
   "list": {

+ 1 - 0
messages/it/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Descrizione",
     "descriptionPlaceholder": "Dettagli facoltativi...",
     "dueDateLabel": "Data di Scadenza",
+    "dueTimeLabel": "Ora (facoltativa)",
     "dueMileageLabel": "Chilometraggio di Scadenza",
     "dueMileagePlaceholder": "es. 50000",
     "reminderUpdated": "Promemoria aggiornato",

+ 60 - 4
messages/lt/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "Šiandien",
   "workOrder": "Darbo užsakymas",
+  "allDay": "Visą dieną",
+  "openDay": "Atidaryti {date}",
+  "dayTotal": "{count, plural, =1 {1 įvykis} one {# įvykis} few {# įvykiai} many {# įvykio} other {# įvykių}}",
+  "views": {
+    "day": "Diena",
+    "week": "Savaitė",
+    "month": "Mėnuo",
+    "year": "Metai",
+    "schedule": "Tvarkaraštis",
+    "fourDays": "4 dienos"
+  },
+  "options": {
+    "showWeekends": "Rodyti savaitgalius",
+    "showCompleted": "Rodyti užbaigtus",
+    "showWeekNumbers": "Rodyti savaičių numerius"
+  },
+  "toolbar": {
+    "previous": "Ankstesnis",
+    "next": "Kitas",
+    "create": "Sukurti",
+    "showSidebar": "Rodyti šoninį skydelį",
+    "hideSidebar": "Slėpti šoninį skydelį"
+  },
+  "sidebar": {
+    "calendars": "Kalendoriai",
+    "legend": "Būsenų spalvos"
+  },
+  "schedule": {
+    "empty": "Nieko nesuplanuota",
+    "emptyHint": "Šiuo laikotarpiu nėra darbo užsakymų, priminimų, pasiūlymų ar žinučių."
+  },
+  "peek": {
+    "close": "Uždaryti",
+    "open": {
+      "service": "Atidaryti darbo užsakymą",
+      "reminder": "Atidaryti priminimą",
+      "quote": "Atidaryti pasiūlymą",
+      "message": "Atidaryti žinutes",
+      "external": "Atidaryti"
+    }
+  },
   "filters": {
-    "services": "Paslaugos",
+    "services": "Darbo užsakymai",
     "reminders": "Priminimai",
     "quotes": "Pasiūlymai",
     "messages": "Žinutės",
@@ -45,21 +86,35 @@
     "useToday": "Naudoti šiandienos datą"
   },
   "more": "+ {count} daugiau",
-  "monthTotal": "{count} įvykių šį mėnesį",
+  "monthTotal": "Įvykių: {count}",
   "contextMenu": {
     "newWorkOrder": "Naujas darbo užsakymas",
+    "newWorkOrderAt": "Naujas darbo užsakymas {time}",
     "newReminder": "Naujas priminimas",
     "newQuote": "Naujas pasiūlymas",
     "openEvent": "Atidaryti ({count})",
     "hint": "Patarimas: spustelėkite dieną dešiniuoju pelės mygtuku, kad pamatytumėte veiksmus",
     "scheduleMessage": "Suplanuoti žinutę"
   },
+  "eventMenu": {
+    "open": "Atidaryti",
+    "markDone": "Pažymėti kaip atliktą",
+    "markNotDone": "Pažymėti kaip neatliktą",
+    "deleteReminder": "Ištrinti priminimą",
+    "deleteReminderConfirm": "Ištrinti priminimą „{title}“? Šio veiksmo atšaukti negalima.",
+    "deleteMessage": "Ištrinti suplanuotą žinutę",
+    "deleteMessageConfirm": "Ištrinti suplanuotą žinutę „{title}“? Ji nebus išsiųsta.",
+    "deleted": "Ištrinta",
+    "updated": "Atnaujinta",
+    "failed": "Nepavyko. Bandykite dar kartą."
+  },
   "events": {
     "status": {
       "completed": "Užbaigta",
       "inProgress": "Vykdoma",
       "waitingParts": "Laukia dalių",
       "pending": "Laukia",
+      "scheduled": "Suplanuota",
       "sent": "Išsiųsta",
       "approved": "Patvirtinta",
       "draft": "Juodraštis",
@@ -71,10 +126,11 @@
       "busy": "Užimta"
     },
     "type": {
-      "service": "Paslauga",
+      "service": "Darbo užsakymas",
       "reminder": "Priminimas",
       "quote": "Pasiūlymas",
-      "message": "Žinutė"
+      "message": "Žinutė",
+      "external": "Prijungtas kalendorius"
     },
     "noEvents": "Įvykių nėra",
     "selectDay": "Pasirinkite dieną su įvykiais arba sukurkite naują darbo užsakymą.",

+ 1 - 0
messages/lt/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Žinutė suplanuota",
     "updated": "Suplanuota žinutė atnaujinta",
     "saveError": "Nepavyko išsaugoti žinutės",
+    "inPast": "Šis laikas jau praėjo. Pasirinkite laiką ateityje.",
     "whatsappNeedsTemplate": "Ši žinutė iškeliaus gerokai užsidarius 24 valandų langui, o „WhatsApp“ teksto šablonas nenustatytas, todėl siuntimas nepavyktų. Nustatykite jį: Nustatymai > Tiekėjai > WhatsApp."
   },
   "list": {

+ 1 - 0
messages/lt/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Aprašymas",
     "descriptionPlaceholder": "Papildoma informacija...",
     "dueDateLabel": "Termino data",
+    "dueTimeLabel": "Laikas (neprivaloma)",
     "dueMileageLabel": "Termino rida",
     "dueMileagePlaceholder": "pvz., 50000",
     "reminderUpdated": "Priminimas atnaujintas",

+ 60 - 4
messages/nb/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "I dag",
   "workOrder": "Arbeidsordre",
+  "allDay": "Hele dagen",
+  "openDay": "Åpne {date}",
+  "dayTotal": "{count, plural, =1 {1 hendelse} other {# hendelser}}",
+  "views": {
+    "day": "Dag",
+    "week": "Uke",
+    "month": "Måned",
+    "year": "År",
+    "schedule": "Tidsplan",
+    "fourDays": "4 dager"
+  },
+  "options": {
+    "showWeekends": "Vis helger",
+    "showCompleted": "Vis fullførte",
+    "showWeekNumbers": "Vis ukenummer"
+  },
+  "toolbar": {
+    "previous": "Forrige",
+    "next": "Neste",
+    "create": "Opprett",
+    "showSidebar": "Vis sidepanel",
+    "hideSidebar": "Skjul sidepanel"
+  },
+  "sidebar": {
+    "calendars": "Kalendere",
+    "legend": "Statusfarger"
+  },
+  "schedule": {
+    "empty": "Ingenting planlagt",
+    "emptyHint": "Ingen arbeidsordrer, påminnelser, tilbud eller meldinger i denne perioden."
+  },
+  "peek": {
+    "close": "Lukk",
+    "open": {
+      "service": "Åpne arbeidsordre",
+      "reminder": "Åpne påminnelse",
+      "quote": "Åpne tilbud",
+      "message": "Åpne meldinger",
+      "external": "Åpne"
+    }
+  },
   "filters": {
-    "services": "Tjenester",
+    "services": "Arbeidsordrer",
     "reminders": "Påminnelser",
     "quotes": "Tilbud",
     "messages": "Meldinger",
@@ -45,21 +86,35 @@
     "useToday": "Use today"
   },
   "more": "+ {count} flere",
-  "monthTotal": "{count} hendelser denne måneden",
+  "monthTotal": "{count} hendelser",
   "contextMenu": {
     "newWorkOrder": "Ny arbeidsordre",
+    "newWorkOrderAt": "Ny arbeidsordre kl. {time}",
     "newReminder": "Ny påminnelse",
     "newQuote": "Nytt tilbud",
     "openEvent": "Åpne ({count})",
     "hint": "Tips: høyreklikk en dag for handlinger",
     "scheduleMessage": "Planlegg melding"
   },
+  "eventMenu": {
+    "open": "Åpne",
+    "markDone": "Merk som fullført",
+    "markNotDone": "Merk som ikke fullført",
+    "deleteReminder": "Slett påminnelse",
+    "deleteReminderConfirm": "Slette påminnelsen «{title}»? Dette kan ikke angres.",
+    "deleteMessage": "Slett planlagt melding",
+    "deleteMessageConfirm": "Slette den planlagte meldingen «{title}»? Den blir ikke sendt.",
+    "deleted": "Slettet",
+    "updated": "Oppdatert",
+    "failed": "Det gikk ikke. Prøv igjen."
+  },
   "events": {
     "status": {
       "completed": "Fullført",
       "inProgress": "Pågår",
       "waitingParts": "Venter på deler",
       "pending": "Ventende",
+      "scheduled": "Planlagt",
       "sent": "Sendt",
       "approved": "Godkjent",
       "draft": "Utkast",
@@ -71,10 +126,11 @@
       "busy": "Opptatt"
     },
     "type": {
-      "service": "Tjeneste",
+      "service": "Arbeidsordre",
       "reminder": "Påminnelse",
       "quote": "Tilbud",
-      "message": "Melding"
+      "message": "Melding",
+      "external": "Tilkoblet kalender"
     },
     "noEvents": "Ingen hendelser",
     "selectDay": "Velg en dag med hendelser eller opprett en ny arbeidsordre.",

+ 1 - 0
messages/nb/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Melding planlagt",
     "updated": "Planlagt melding oppdatert",
     "saveError": "Kunne ikke lagre meldingen",
+    "inPast": "Dette tidspunktet er allerede passert. Velg et tidspunkt fram i tid.",
     "whatsappNeedsTemplate": "Denne sendes lenge etter at kundens 24-timersvindu er lukket, og ingen WhatsApp-tekstmal er satt opp, så den ville feile ved sending. Sett opp en under Innstillinger > Leverandører > WhatsApp."
   },
   "list": {

+ 1 - 0
messages/nb/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Beskrivelse",
     "descriptionPlaceholder": "Valgfrie detaljer...",
     "dueDateLabel": "Forfallsdato",
+    "dueTimeLabel": "Klokkeslett (valgfritt)",
     "dueMileageLabel": "Kilometerstand ved forfall",
     "dueMileagePlaceholder": "f.eks. 50000",
     "reminderUpdated": "Påminnelse oppdatert",

+ 60 - 4
messages/nl/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "Vandaag",
   "workOrder": "Werkorder",
+  "allDay": "Hele dag",
+  "openDay": "{date} openen",
+  "dayTotal": "{count, plural, =1 {1 gebeurtenis} other {# gebeurtenissen}}",
+  "views": {
+    "day": "Dag",
+    "week": "Week",
+    "month": "Maand",
+    "year": "Jaar",
+    "schedule": "Planning",
+    "fourDays": "4 dagen"
+  },
+  "options": {
+    "showWeekends": "Weekenden tonen",
+    "showCompleted": "Afgeronde tonen",
+    "showWeekNumbers": "Weeknummers tonen"
+  },
+  "toolbar": {
+    "previous": "Vorige",
+    "next": "Volgende",
+    "create": "Aanmaken",
+    "showSidebar": "Zijpaneel tonen",
+    "hideSidebar": "Zijpaneel verbergen"
+  },
+  "sidebar": {
+    "calendars": "Agenda's",
+    "legend": "Statuskleuren"
+  },
+  "schedule": {
+    "empty": "Niets gepland",
+    "emptyHint": "Geen werkorders, herinneringen, offertes of berichten in deze periode."
+  },
+  "peek": {
+    "close": "Sluiten",
+    "open": {
+      "service": "Werkorder openen",
+      "reminder": "Herinnering openen",
+      "quote": "Offerte openen",
+      "message": "Berichten openen",
+      "external": "Openen"
+    }
+  },
   "filters": {
-    "services": "Diensten",
+    "services": "Werkorders",
     "reminders": "Herinneringen",
     "quotes": "Offertes",
     "messages": "Berichten",
@@ -45,21 +86,35 @@
     "useToday": "Use today"
   },
   "more": "+ {count} meer",
-  "monthTotal": "{count} items deze maand",
+  "monthTotal": "{count} gebeurtenissen",
   "contextMenu": {
     "newWorkOrder": "Nieuwe werkorder",
+    "newWorkOrderAt": "Nieuwe werkorder om {time}",
     "newReminder": "Nieuwe herinnering",
     "newQuote": "Nieuwe offerte",
     "openEvent": "Openen ({count})",
     "hint": "Tip: klik met de rechtermuisknop op een dag voor acties",
     "scheduleMessage": "Bericht plannen"
   },
+  "eventMenu": {
+    "open": "Openen",
+    "markDone": "Markeren als afgerond",
+    "markNotDone": "Markeren als niet afgerond",
+    "deleteReminder": "Herinnering verwijderen",
+    "deleteReminderConfirm": "Herinnering \"{title}\" verwijderen? Dit kan niet ongedaan worden gemaakt.",
+    "deleteMessage": "Gepland bericht verwijderen",
+    "deleteMessageConfirm": "Gepland bericht \"{title}\" verwijderen? Het wordt niet verzonden.",
+    "deleted": "Verwijderd",
+    "updated": "Bijgewerkt",
+    "failed": "Dat is niet gelukt. Probeer het opnieuw."
+  },
   "events": {
     "status": {
       "completed": "Voltooid",
       "inProgress": "In uitvoering",
       "waitingParts": "Wacht op onderdelen",
       "pending": "In afwachting",
+      "scheduled": "Ingepland",
       "sent": "Verzonden",
       "approved": "Goedgekeurd",
       "draft": "Concept",
@@ -71,10 +126,11 @@
       "busy": "Bezet"
     },
     "type": {
-      "service": "Dienst",
+      "service": "Werkorder",
       "reminder": "Herinnering",
       "quote": "Offerte",
-      "message": "Bericht"
+      "message": "Bericht",
+      "external": "Gekoppelde agenda"
     },
     "noEvents": "Geen evenementen",
     "selectDay": "Selecteer een dag met evenementen of maak een nieuwe werkorder aan.",

+ 1 - 0
messages/nl/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Bericht gepland",
     "updated": "Gepland bericht bijgewerkt",
     "saveError": "Kan het bericht niet opslaan",
+    "inPast": "Dat tijdstip is al voorbij. Kies een tijdstip in de toekomst.",
     "whatsappNeedsTemplate": "Dit wordt ruim na het sluiten van het venster van 24 uur verstuurd en er is geen WhatsApp-tekstsjabloon ingesteld, dus het zou mislukken. Stel er een in via Instellingen > Providers > WhatsApp."
   },
   "list": {

+ 1 - 0
messages/nl/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Beschrijving",
     "descriptionPlaceholder": "Optionele details...",
     "dueDateLabel": "Vervaldatum",
+    "dueTimeLabel": "Tijd (optioneel)",
     "dueMileageLabel": "Verval kilometerstand",
     "dueMileagePlaceholder": "bijv. 50000",
     "reminderUpdated": "Herinnering bijgewerkt",

+ 60 - 4
messages/pl/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "Dziś",
   "workOrder": "Zlecenie",
+  "allDay": "Cały dzień",
+  "openDay": "Otwórz {date}",
+  "dayTotal": "{count, plural, =1 {1 wydarzenie} one {# wydarzenie} few {# wydarzenia} many {# wydarzeń} other {# wydarzenia}}",
+  "views": {
+    "day": "Dzień",
+    "week": "Tydzień",
+    "month": "Miesiąc",
+    "year": "Rok",
+    "schedule": "Harmonogram",
+    "fourDays": "4 dni"
+  },
+  "options": {
+    "showWeekends": "Pokaż weekendy",
+    "showCompleted": "Pokaż zakończone",
+    "showWeekNumbers": "Pokaż numery tygodni"
+  },
+  "toolbar": {
+    "previous": "Poprzedni",
+    "next": "Następny",
+    "create": "Utwórz",
+    "showSidebar": "Pokaż panel boczny",
+    "hideSidebar": "Ukryj panel boczny"
+  },
+  "sidebar": {
+    "calendars": "Kalendarze",
+    "legend": "Kolory statusów"
+  },
+  "schedule": {
+    "empty": "Nic nie zaplanowano",
+    "emptyHint": "Brak zleceń, przypomnień, wycen i wiadomości w tym okresie."
+  },
+  "peek": {
+    "close": "Zamknij",
+    "open": {
+      "service": "Otwórz zlecenie",
+      "reminder": "Otwórz przypomnienie",
+      "quote": "Otwórz wycenę",
+      "message": "Otwórz wiadomości",
+      "external": "Otwórz"
+    }
+  },
   "filters": {
-    "services": "Usługi",
+    "services": "Zlecenia",
     "reminders": "Przypomnienia",
     "quotes": "Wyceny",
     "messages": "Wiadomości",
@@ -45,21 +86,35 @@
     "useToday": "Use today"
   },
   "more": "+ {count} więcej",
-  "monthTotal": "{count} wydarzeń w tym miesiącu",
+  "monthTotal": "Wydarzenia: {count}",
   "contextMenu": {
     "newWorkOrder": "Nowe zlecenie",
+    "newWorkOrderAt": "Nowe zlecenie o {time}",
     "newReminder": "Nowe przypomnienie",
     "newQuote": "Nowa wycena",
     "openEvent": "Otwórz ({count})",
     "hint": "Wskazówka: kliknij dzień prawym przyciskiem myszy, aby zobaczyć akcje",
     "scheduleMessage": "Zaplanuj wiadomość"
   },
+  "eventMenu": {
+    "open": "Otwórz",
+    "markDone": "Oznacz jako wykonane",
+    "markNotDone": "Oznacz jako niewykonane",
+    "deleteReminder": "Usuń przypomnienie",
+    "deleteReminderConfirm": "Usunąć przypomnienie „{title}”? Tej operacji nie można cofnąć.",
+    "deleteMessage": "Usuń zaplanowaną wiadomość",
+    "deleteMessageConfirm": "Usunąć zaplanowaną wiadomość „{title}”? Nie zostanie wysłana.",
+    "deleted": "Usunięto",
+    "updated": "Zaktualizowano",
+    "failed": "Nie udało się. Spróbuj ponownie."
+  },
   "events": {
     "status": {
       "completed": "Ukończono",
       "inProgress": "W trakcie",
       "waitingParts": "Oczekiwanie na części",
       "pending": "Oczekujące",
+      "scheduled": "Zaplanowane",
       "sent": "Wysłano",
       "approved": "Zatwierdzone",
       "draft": "Szkic",
@@ -71,10 +126,11 @@
       "busy": "Zajęte"
     },
     "type": {
-      "service": "Usługa",
+      "service": "Zlecenie",
       "reminder": "Przypomnienie",
       "quote": "Wycena",
-      "message": "Wiadomość"
+      "message": "Wiadomość",
+      "external": "Połączony kalendarz"
     },
     "noEvents": "Brak wydarzeń",
     "selectDay": "Wybierz dzień z wydarzeniami lub utwórz nowe zlecenie.",

+ 1 - 0
messages/pl/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Wiadomość zaplanowana",
     "updated": "Zaplanowana wiadomość zaktualizowana",
     "saveError": "Nie udało się zapisać wiadomości",
+    "inPast": "Ta godzina już minęła. Wybierz termin w przyszłości.",
     "whatsappNeedsTemplate": "Wiadomość wyjdzie długo po zamknięciu 24-godzinnego okna, a szablon tekstowy WhatsApp nie jest skonfigurowany, więc wysyłka by się nie powiodła. Skonfiguruj go w Ustawienia > Dostawcy > WhatsApp."
   },
   "list": {

+ 1 - 0
messages/pl/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Opis",
     "descriptionPlaceholder": "Opcjonalne szczegóły...",
     "dueDateLabel": "Termin",
+    "dueTimeLabel": "Godzina (opcjonalnie)",
     "dueMileageLabel": "Przebieg terminu",
     "dueMileagePlaceholder": "np. 50000",
     "reminderUpdated": "Przypomnienie zaktualizowane",

+ 60 - 4
messages/pt-BR/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "Hoje",
   "workOrder": "Ordem de serviço",
+  "allDay": "Dia inteiro",
+  "openDay": "Abrir {date}",
+  "dayTotal": "{count, plural, =1 {1 evento} other {# eventos}}",
+  "views": {
+    "day": "Dia",
+    "week": "Semana",
+    "month": "Mês",
+    "year": "Ano",
+    "schedule": "Agenda",
+    "fourDays": "4 dias"
+  },
+  "options": {
+    "showWeekends": "Mostrar fins de semana",
+    "showCompleted": "Mostrar concluídos",
+    "showWeekNumbers": "Mostrar números das semanas"
+  },
+  "toolbar": {
+    "previous": "Anterior",
+    "next": "Próximo",
+    "create": "Criar",
+    "showSidebar": "Mostrar painel lateral",
+    "hideSidebar": "Ocultar painel lateral"
+  },
+  "sidebar": {
+    "calendars": "Calendários",
+    "legend": "Cores de status"
+  },
+  "schedule": {
+    "empty": "Nada agendado",
+    "emptyHint": "Nenhuma ordem de serviço, lembrete, orçamento ou mensagem neste período."
+  },
+  "peek": {
+    "close": "Fechar",
+    "open": {
+      "service": "Abrir ordem de serviço",
+      "reminder": "Abrir lembrete",
+      "quote": "Abrir orçamento",
+      "message": "Abrir mensagens",
+      "external": "Abrir"
+    }
+  },
   "filters": {
-    "services": "Serviços",
+    "services": "Ordens de serviço",
     "reminders": "Lembretes",
     "quotes": "Orçamentos",
     "messages": "Mensagens",
@@ -45,21 +86,35 @@
     "useToday": "Use today"
   },
   "more": "+ {count} mais",
-  "monthTotal": "{count} eventos neste mês",
+  "monthTotal": "{count} eventos",
   "contextMenu": {
     "newWorkOrder": "Nova ordem de serviço",
+    "newWorkOrderAt": "Nova ordem de serviço às {time}",
     "newReminder": "Novo lembrete",
     "newQuote": "Novo orçamento",
     "openEvent": "Abrir ({count})",
     "hint": "Dica: clique com o botão direito em um dia para ver as ações",
     "scheduleMessage": "Agendar mensagem"
   },
+  "eventMenu": {
+    "open": "Abrir",
+    "markDone": "Marcar como concluído",
+    "markNotDone": "Marcar como não concluído",
+    "deleteReminder": "Excluir lembrete",
+    "deleteReminderConfirm": "Excluir o lembrete \"{title}\"? Isso não pode ser desfeito.",
+    "deleteMessage": "Excluir mensagem agendada",
+    "deleteMessageConfirm": "Excluir a mensagem agendada \"{title}\"? Ela não será enviada.",
+    "deleted": "Excluído",
+    "updated": "Atualizado",
+    "failed": "Não foi possível concluir. Tente novamente."
+  },
   "events": {
     "status": {
       "completed": "Concluído",
       "inProgress": "Em andamento",
       "waitingParts": "Aguardando peças",
       "pending": "Pendente",
+      "scheduled": "Agendado",
       "sent": "Enviado",
       "approved": "Aprovado",
       "draft": "Rascunho",
@@ -71,10 +126,11 @@
       "busy": "Ocupado"
     },
     "type": {
-      "service": "Serviço",
+      "service": "Ordem de serviço",
       "reminder": "Lembrete",
       "quote": "Orçamento",
-      "message": "Mensagem"
+      "message": "Mensagem",
+      "external": "Calendário conectado"
     },
     "noEvents": "Sem eventos",
     "selectDay": "Selecione um dia com eventos ou crie uma nova ordem de serviço.",

+ 1 - 0
messages/pt-BR/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Mensagem agendada",
     "updated": "Mensagem agendada atualizada",
     "saveError": "Não foi possível salvar a mensagem",
+    "inPast": "Esse horário já passou. Escolha um horário futuro.",
     "whatsappNeedsTemplate": "Isto será enviado bem depois de a janela de 24 horas fechar e não há modelo de texto do WhatsApp configurado, então falharia no envio. Configure um em Configurações > Provedores > WhatsApp."
   },
   "list": {

+ 1 - 0
messages/pt-BR/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Descrição",
     "descriptionPlaceholder": "Detalhes opcionais...",
     "dueDateLabel": "Data de Vencimento",
+    "dueTimeLabel": "Horário (opcional)",
     "dueMileageLabel": "Quilometragem de Vencimento",
     "dueMileagePlaceholder": "ex. 50000",
     "reminderUpdated": "Lembrete atualizado",

+ 60 - 4
messages/ru/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "Сегодня",
   "workOrder": "Заказ-наряд",
+  "allDay": "Весь день",
+  "openDay": "Открыть {date}",
+  "dayTotal": "{count, plural, =1 {1 событие} one {# событие} few {# события} many {# событий} other {# события}}",
+  "views": {
+    "day": "День",
+    "week": "Неделя",
+    "month": "Месяц",
+    "year": "Год",
+    "schedule": "Расписание",
+    "fourDays": "4 дня"
+  },
+  "options": {
+    "showWeekends": "Показывать выходные",
+    "showCompleted": "Показывать завершённые",
+    "showWeekNumbers": "Показывать номера недель"
+  },
+  "toolbar": {
+    "previous": "Назад",
+    "next": "Вперёд",
+    "create": "Создать",
+    "showSidebar": "Показать боковую панель",
+    "hideSidebar": "Скрыть боковую панель"
+  },
+  "sidebar": {
+    "calendars": "Календари",
+    "legend": "Цвета статусов"
+  },
+  "schedule": {
+    "empty": "Ничего не запланировано",
+    "emptyHint": "В этом периоде нет заказ-нарядов, напоминаний, смет и сообщений."
+  },
+  "peek": {
+    "close": "Закрыть",
+    "open": {
+      "service": "Открыть заказ-наряд",
+      "reminder": "Открыть напоминание",
+      "quote": "Открыть смету",
+      "message": "Открыть сообщения",
+      "external": "Открыть"
+    }
+  },
   "filters": {
-    "services": "Обслуживания",
+    "services": "Заказ-наряды",
     "reminders": "Напоминания",
     "quotes": "Предложения",
     "messages": "Сообщения",
@@ -45,21 +86,35 @@
     "useToday": "Сегодня"
   },
   "more": "+ ещё {count}",
-  "monthTotal": "{count} событий в этом месяце",
+  "monthTotal": "Событий: {count}",
   "contextMenu": {
     "newWorkOrder": "Новый заказ-наряд",
+    "newWorkOrderAt": "Новый заказ-наряд в {time}",
     "newReminder": "Новое напоминание",
     "newQuote": "Новое предложение",
     "openEvent": "Открыть ({count})",
     "hint": "Совет: щёлкните по дню правой кнопкой мыши, чтобы увидеть действия",
     "scheduleMessage": "Запланировать сообщение"
   },
+  "eventMenu": {
+    "open": "Открыть",
+    "markDone": "Отметить как выполненное",
+    "markNotDone": "Отметить как невыполненное",
+    "deleteReminder": "Удалить напоминание",
+    "deleteReminderConfirm": "Удалить напоминание «{title}»? Это действие нельзя отменить.",
+    "deleteMessage": "Удалить запланированное сообщение",
+    "deleteMessageConfirm": "Удалить запланированное сообщение «{title}»? Оно не будет отправлено.",
+    "deleted": "Удалено",
+    "updated": "Обновлено",
+    "failed": "Не получилось. Попробуйте ещё раз."
+  },
   "events": {
     "status": {
       "completed": "Завершено",
       "inProgress": "В работе",
       "waitingParts": "Ожидание запчастей",
       "pending": "Ожидает",
+      "scheduled": "Запланировано",
       "sent": "Отправлено",
       "approved": "Одобрено",
       "draft": "Черновик",
@@ -71,10 +126,11 @@
       "busy": "Занято"
     },
     "type": {
-      "service": "Обслуживание",
+      "service": "Заказ-наряд",
       "reminder": "Напоминание",
       "quote": "Предложение",
-      "message": "Сообщение"
+      "message": "Сообщение",
+      "external": "Подключённый календарь"
     },
     "noEvents": "Нет событий",
     "selectDay": "Выберите день с событиями или создайте новый заказ-наряд.",

+ 1 - 0
messages/ru/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Сообщение запланировано",
     "updated": "Запланированное сообщение обновлено",
     "saveError": "Не удалось сохранить сообщение",
+    "inPast": "Это время уже прошло. Выберите время в будущем.",
     "whatsappNeedsTemplate": "Сообщение уйдёт значительно позже закрытия 24-часового окна, а текстовый шаблон WhatsApp не настроен, поэтому отправка не удастся. Настройте его в Настройки > Провайдеры > WhatsApp."
   },
   "list": {

+ 1 - 0
messages/ru/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Описание",
     "descriptionPlaceholder": "Дополнительные сведения...",
     "dueDateLabel": "Дата срока",
+    "dueTimeLabel": "Время (необязательно)",
     "dueMileageLabel": "Пробег до срока",
     "dueMileagePlaceholder": "напр. 50000",
     "reminderUpdated": "Напоминание обновлено",

+ 60 - 4
messages/tr/calendar.json

@@ -30,8 +30,49 @@
   },
   "today": "Bugün",
   "workOrder": "İş emri",
+  "allDay": "Tüm gün",
+  "openDay": "{date} tarihini aç",
+  "dayTotal": "{count, plural, =1 {1 etkinlik} other {# etkinlik}}",
+  "views": {
+    "day": "Gün",
+    "week": "Hafta",
+    "month": "Ay",
+    "year": "Yıl",
+    "schedule": "Program",
+    "fourDays": "4 gün"
+  },
+  "options": {
+    "showWeekends": "Hafta sonlarını göster",
+    "showCompleted": "Tamamlananları göster",
+    "showWeekNumbers": "Hafta numaralarını göster"
+  },
+  "toolbar": {
+    "previous": "Önceki",
+    "next": "Sonraki",
+    "create": "Oluştur",
+    "showSidebar": "Yan paneli göster",
+    "hideSidebar": "Yan paneli gizle"
+  },
+  "sidebar": {
+    "calendars": "Takvimler",
+    "legend": "Durum renkleri"
+  },
+  "schedule": {
+    "empty": "Planlanmış bir şey yok",
+    "emptyHint": "Bu dönemde iş emri, hatırlatıcı, teklif veya mesaj yok."
+  },
+  "peek": {
+    "close": "Kapat",
+    "open": {
+      "service": "İş emrini aç",
+      "reminder": "Hatırlatıcıyı aç",
+      "quote": "Teklifi aç",
+      "message": "Mesajları aç",
+      "external": "Aç"
+    }
+  },
   "filters": {
-    "services": "Servisler",
+    "services": "İş emirleri",
     "reminders": "Hatırlatıcılar",
     "quotes": "Teklifler",
     "messages": "Mesajlar",
@@ -45,21 +86,35 @@
     "useToday": "Use today"
   },
   "more": "+ {count} daha",
-  "monthTotal": "bu ay {count} kayıt",
+  "monthTotal": "{count} etkinlik",
   "contextMenu": {
     "newWorkOrder": "Yeni iş emri",
+    "newWorkOrderAt": "{time} için yeni iş emri",
     "newReminder": "Yeni hatırlatıcı",
     "newQuote": "Yeni teklif",
     "openEvent": "Aç ({count})",
     "hint": "İpucu: işlemler için bir güne sağ tıklayın",
     "scheduleMessage": "Mesaj planla"
   },
+  "eventMenu": {
+    "open": "Aç",
+    "markDone": "Tamamlandı olarak işaretle",
+    "markNotDone": "Tamamlanmadı olarak işaretle",
+    "deleteReminder": "Hatırlatıcıyı sil",
+    "deleteReminderConfirm": "\"{title}\" hatırlatıcısı silinsin mi? Bu işlem geri alınamaz.",
+    "deleteMessage": "Planlanmış mesajı sil",
+    "deleteMessageConfirm": "\"{title}\" planlanmış mesajı silinsin mi? Gönderilmeyecek.",
+    "deleted": "Silindi",
+    "updated": "Güncellendi",
+    "failed": "Olmadı. Tekrar deneyin."
+  },
   "events": {
     "status": {
       "completed": "Tamamlandı",
       "inProgress": "Devam ediyor",
       "waitingParts": "Parça bekleniyor",
       "pending": "Beklemede",
+      "scheduled": "Planlandı",
       "sent": "Gönderildi",
       "approved": "Onaylandı",
       "draft": "Taslak",
@@ -71,10 +126,11 @@
       "busy": "Meşgul"
     },
     "type": {
-      "service": "Servis",
+      "service": "İş emri",
       "reminder": "Hatırlatıcı",
       "quote": "Teklif",
-      "message": "Mesaj"
+      "message": "Mesaj",
+      "external": "Bağlı takvim"
     },
     "noEvents": "Etkinlik yok",
     "selectDay": "Etkinliği olan bir gün seçin veya yeni bir iş emri oluşturun.",

+ 1 - 0
messages/tr/scheduledMessages.json

@@ -41,6 +41,7 @@
     "scheduled": "Mesaj planlandı",
     "updated": "Planlanmış mesaj güncellendi",
     "saveError": "Mesaj kaydedilemedi",
+    "inPast": "Bu saat çoktan geçti. İleri bir saat seçin.",
     "whatsappNeedsTemplate": "Bu mesaj müşterinin 24 saatlik penceresi kapandıktan çok sonra gidecek ve bir WhatsApp metin şablonu kurulu değil, bu yüzden gönderimde başarısız olur. Ayarlar > Sağlayıcılar > WhatsApp altından bir tane kurun."
   },
   "list": {

+ 1 - 0
messages/tr/vehicles.json

@@ -140,6 +140,7 @@
     "descriptionLabel": "Açıklama",
     "descriptionPlaceholder": "İsteğe bağlı detaylar...",
     "dueDateLabel": "Son Tarih",
+    "dueTimeLabel": "Saat (isteğe bağlı)",
     "dueMileageLabel": "Kilometre Sınırı",
     "dueMileagePlaceholder": "örn. 50000",
     "reminderUpdated": "Hatırlatıcı güncellendi",

+ 2 - 0
prisma/migrations/20260906161411_reminder_due_time/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "reminders" ADD COLUMN     "hasDueTime" BOOLEAN NOT NULL DEFAULT false;

+ 3 - 0
prisma/schema/vehicles.prisma

@@ -96,6 +96,9 @@ model Reminder {
   title       String
   description String?
   dueDate     DateTime?
+  /// True when the time of day in dueDate was chosen, not the noon a
+  /// day-only reminder is stored at
+  hasDueTime  Boolean   @default(false)
   dueMileage  Int?
   isCompleted Boolean   @default(false)
   /// Per-reminder notification channels, chosen when creating the reminder

+ 213 - 0
src/__tests__/features/calendar/calendar-range.test.ts

@@ -0,0 +1,213 @@
+import { describe, expect, it } from 'vitest'
+import {
+  addMonths,
+  eventSpan,
+  getMonthGridDays,
+  isoWeekNumber,
+  layoutTimedEvents,
+  parseDateKey,
+  rangeCovers,
+  shiftDate,
+  startOfWeek,
+  timeToMinutes,
+  toLocalDateStr,
+  visibleRange,
+} from '@/features/calendar/Lib/calendar-range'
+
+const d = (s: string) => parseDateKey(s)!
+
+describe('parseDateKey', () => {
+  it('reads YYYY-MM-DD as a local date', () => {
+    const date = parseDateKey('2026-09-06')!
+    expect(date.getFullYear()).toBe(2026)
+    expect(date.getMonth()).toBe(8)
+    expect(date.getDate()).toBe(6)
+    expect(date.getHours()).toBe(0)
+  })
+
+  it('rejects anything else', () => {
+    expect(parseDateKey('2026-9-6')).toBeNull()
+    expect(parseDateKey('tomorrow')).toBeNull()
+    expect(parseDateKey('')).toBeNull()
+    expect(parseDateKey(undefined)).toBeNull()
+  })
+
+  it('round-trips through toLocalDateStr', () => {
+    expect(toLocalDateStr(d('2026-01-31'))).toBe('2026-01-31')
+  })
+})
+
+describe('startOfWeek', () => {
+  it('honours a Monday start', () => {
+    // 6 Sep 2026 is a Sunday
+    expect(toLocalDateStr(startOfWeek(d('2026-09-06'), 1))).toBe('2026-08-31')
+  })
+
+  it('honours a Sunday start', () => {
+    expect(toLocalDateStr(startOfWeek(d('2026-09-06'), 0))).toBe('2026-09-06')
+  })
+
+  it('honours a Saturday start', () => {
+    expect(toLocalDateStr(startOfWeek(d('2026-09-06'), 6))).toBe('2026-09-05')
+  })
+})
+
+describe('getMonthGridDays', () => {
+  it('pads to whole weeks from the week start', () => {
+    const days = getMonthGridDays(2026, 8, 1) // September 2026, Monday start
+    expect(days.length % 7).toBe(0)
+    expect(toLocalDateStr(days[0])).toBe('2026-08-31')
+    expect(toLocalDateStr(days[days.length - 1])).toBe('2026-10-04')
+  })
+
+  it('needs six rows for a month that spills far', () => {
+    // May 2026 starts on a Friday and has 31 days: Monday start needs 5 rows
+    expect(getMonthGridDays(2026, 4, 1).length).toBe(35)
+    // August 2026 starts on a Saturday: 6 rows
+    expect(getMonthGridDays(2026, 7, 1).length).toBe(42)
+  })
+})
+
+describe('addMonths', () => {
+  it('clamps the day at the end of a shorter month', () => {
+    expect(toLocalDateStr(addMonths(d('2026-01-31'), 1))).toBe('2026-02-28')
+    expect(toLocalDateStr(addMonths(d('2026-03-31'), -1))).toBe('2026-02-28')
+  })
+
+  it('crosses the year', () => {
+    expect(toLocalDateStr(addMonths(d('2026-12-15'), 1))).toBe('2027-01-15')
+  })
+})
+
+describe('visibleRange', () => {
+  it('day is the day', () => {
+    const r = visibleRange('day', d('2026-09-06'), 1)
+    expect(toLocalDateStr(r.start)).toBe('2026-09-06')
+    expect(toLocalDateStr(r.end)).toBe('2026-09-06')
+  })
+
+  it('four days start on the date', () => {
+    const r = visibleRange('fourDays', d('2026-09-06'), 1)
+    expect(toLocalDateStr(r.end)).toBe('2026-09-09')
+  })
+
+  it('week snaps to the week start', () => {
+    const r = visibleRange('week', d('2026-09-06'), 1)
+    expect(toLocalDateStr(r.start)).toBe('2026-08-31')
+    expect(toLocalDateStr(r.end)).toBe('2026-09-06')
+  })
+
+  it('month and schedule show the padded grid', () => {
+    for (const view of ['month', 'schedule'] as const) {
+      const r = visibleRange(view, d('2026-09-06'), 1)
+      expect(toLocalDateStr(r.start)).toBe('2026-08-31')
+      expect(toLocalDateStr(r.end)).toBe('2026-10-04')
+    }
+  })
+
+  it('year is the calendar year', () => {
+    const r = visibleRange('year', d('2026-09-06'), 1)
+    expect(toLocalDateStr(r.start)).toBe('2026-01-01')
+    expect(toLocalDateStr(r.end)).toBe('2026-12-31')
+  })
+})
+
+describe('shiftDate', () => {
+  it('moves by the size of the view', () => {
+    const base = d('2026-09-06')
+    expect(toLocalDateStr(shiftDate('day', base, 1))).toBe('2026-09-07')
+    expect(toLocalDateStr(shiftDate('fourDays', base, 1))).toBe('2026-09-10')
+    expect(toLocalDateStr(shiftDate('week', base, -1))).toBe('2026-08-30')
+    expect(toLocalDateStr(shiftDate('month', base, 1))).toBe('2026-10-06')
+    expect(toLocalDateStr(shiftDate('schedule', base, -1))).toBe('2026-08-06')
+    expect(toLocalDateStr(shiftDate('year', base, 1))).toBe('2027-09-06')
+  })
+
+  it('keeps a leap day sane when jumping a year', () => {
+    expect(toLocalDateStr(shiftDate('year', d('2028-02-29'), 1))).toBe('2029-02-28')
+  })
+})
+
+describe('rangeCovers', () => {
+  const outer = { start: d('2026-08-31'), end: d('2026-10-04') }
+  it('is true when the inner range fits', () => {
+    expect(rangeCovers(outer, { start: d('2026-09-07'), end: d('2026-09-13') })).toBe(true)
+  })
+  it('is false when the inner range spills', () => {
+    expect(rangeCovers(outer, { start: d('2026-10-03'), end: d('2026-10-06') })).toBe(false)
+    expect(rangeCovers(null, { start: d('2026-09-07'), end: d('2026-09-13') })).toBe(false)
+  })
+})
+
+describe('isoWeekNumber', () => {
+  it('matches ISO 8601', () => {
+    expect(isoWeekNumber(d('2026-01-01'))).toBe(1)
+    expect(isoWeekNumber(d('2027-01-01'))).toBe(53)
+    expect(isoWeekNumber(d('2026-09-06'))).toBe(36)
+  })
+})
+
+describe('eventSpan', () => {
+  it('is null for an all-day item', () => {
+    expect(eventSpan({ type: 'reminder', time: null })).toBeNull()
+  })
+
+  it('uses the record end when it has one', () => {
+    expect(eventSpan({ type: 'service', time: '09:00', endTime: '11:30' })).toEqual({
+      startMins: 540,
+      endMins: 690,
+    })
+  })
+
+  it('falls back to a default length per type', () => {
+    expect(eventSpan({ type: 'service', time: '09:00' })!.endMins).toBe(600)
+    expect(eventSpan({ type: 'message', time: '09:00' })!.endMins).toBe(570)
+  })
+
+  it('never ends before it starts', () => {
+    expect(eventSpan({ type: 'service', time: '09:00', endTime: '08:00' })!.endMins).toBe(600)
+  })
+
+  it('reads clock strings defensively', () => {
+    expect(timeToMinutes('7:05')).toBe(425)
+    expect(timeToMinutes('nope')).toBe(0)
+    expect(timeToMinutes(null)).toBe(0)
+  })
+})
+
+describe('layoutTimedEvents', () => {
+  const item = (id: string, start: number, end: number) => ({
+    item: id,
+    span: { startMins: start, endMins: end },
+  })
+
+  it('gives non-overlapping events the full width', () => {
+    const out = layoutTimedEvents([item('a', 540, 600), item('b', 600, 660)])
+    expect(out.map((p) => [p.item, p.column, p.columns])).toEqual([
+      ['a', 0, 1],
+      ['b', 0, 1],
+    ])
+  })
+
+  it('splits a double booking side by side', () => {
+    const out = layoutTimedEvents([item('a', 540, 660), item('b', 570, 630), item('c', 600, 720)])
+    const byId = Object.fromEntries(out.map((p) => [p.item, p]))
+    expect(byId.a.columns).toBe(3)
+    expect(byId.b.columns).toBe(3)
+    expect(byId.c.columns).toBe(3)
+    expect(new Set([byId.a.column, byId.b.column, byId.c.column]).size).toBe(3)
+  })
+
+  it('reuses a column once the earlier event has ended', () => {
+    const out = layoutTimedEvents([item('a', 540, 600), item('b', 570, 630), item('c', 600, 660)])
+    const byId = Object.fromEntries(out.map((p) => [p.item, p]))
+    // c starts as a ends, so it takes a's column; the cluster is two wide
+    expect(byId.c.column).toBe(0)
+    expect(byId.c.columns).toBe(2)
+  })
+
+  it('stretches very short events to a clickable minimum', () => {
+    const out = layoutTimedEvents([item('a', 540, 542)])
+    expect(out[0].endMins - out[0].startMins).toBe(20)
+  })
+})

+ 262 - 0
src/__tests__/features/calendar/time-grid-interactions.test.tsx

@@ -0,0 +1,262 @@
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import type { CalendarEvent } from '@/features/calendar/Actions/calendarActions'
+
+vi.mock('@/components/date-settings-context', () => ({
+  useDateSettings: () => ({
+    dateFormat: 'MMM d, yyyy',
+    timeFormat: '24h',
+    timezone: '',
+    weekStartDay: 1,
+  }),
+}))
+
+vi.mock('@/components/currency-settings-context', () => ({
+  useFormatCurrency: () => (amount: number, code: string) => `${amount} ${code}`,
+}))
+
+const push = vi.fn()
+vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) }))
+
+const confirmMock = vi.fn()
+vi.mock('@/components/confirm-dialog', () => ({ useConfirm: () => confirmMock }))
+
+const deleteScheduledMessage = vi.fn()
+vi.mock('@/features/scheduled-messages/Actions/scheduledMessageActions', () => ({
+  deleteScheduledMessage: (...args: unknown[]) => deleteScheduledMessage(...args),
+}))
+const toggleReminder = vi.fn()
+const deleteReminder = vi.fn()
+vi.mock('@/features/vehicles/Actions/reminderActions', () => ({
+  toggleReminder: (...args: unknown[]) => toggleReminder(...args),
+  deleteReminder: (...args: unknown[]) => deleteReminder(...args),
+}))
+
+// The global next-intl mock has no useFormatter; the calendar's own wrapper
+// is what the components import, so give it a plain Intl formatter.
+vi.mock('@/features/calendar/Components/useDayFormatter', () => ({
+  useDayFormatter: () => ({
+    dateTime: (date: Date, options: Intl.DateTimeFormatOptions) =>
+      new Intl.DateTimeFormat('en-US', options).format(date),
+  }),
+}))
+
+import { TimeGridView } from '@/features/calendar/Components/TimeGridView'
+import { EventPeekProvider } from '@/features/calendar/Components/EventPeek'
+
+beforeAll(() => {
+  // The fixtures live in September 2026; the clock stands still in August so
+  // they stay in the future however long the tests survive. Only Date is
+  // faked: Radix and testing-library still need real timers.
+  vi.useFakeTimers({ toFake: ['Date'] })
+  vi.setSystemTime(new Date(2026, 7, 15, 12, 0))
+  // jsdom has neither; Radix popovers and the slot anchor need both.
+  class RO {
+    observe() {
+      return undefined
+    }
+    unobserve() {
+      return undefined
+    }
+    disconnect() {
+      return undefined
+    }
+  }
+  ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = RO
+  if (typeof (globalThis as { DOMRect?: unknown }).DOMRect === 'undefined') {
+    ;(globalThis as unknown as { DOMRect: unknown }).DOMRect = class {
+      x: number
+      y: number
+      width: number
+      height: number
+      top: number
+      left: number
+      right: number
+      bottom: number
+      constructor(x = 0, y = 0, width = 0, height = 0) {
+        this.x = x
+        this.y = y
+        this.width = width
+        this.height = height
+        this.top = y
+        this.left = x
+        this.right = x + width
+        this.bottom = y + height
+      }
+      toJSON() {
+        return this
+      }
+    }
+  }
+})
+
+afterAll(() => {
+  vi.useRealTimers()
+})
+
+const day = (s: string) => {
+  const [y, m, d] = s.split('-').map(Number)
+  return new Date(y, m - 1, d)
+}
+
+const days = [
+  '2026-08-31',
+  '2026-09-01',
+  '2026-09-02',
+  '2026-09-03',
+  '2026-09-04',
+  '2026-09-05',
+  '2026-09-06',
+].map(day)
+
+const job: CalendarEvent = {
+  id: 's1',
+  title: 'Brake service',
+  date: '2026-09-03',
+  time: '09:00',
+  endTime: '10:00',
+  type: 'service',
+  status: 'pending',
+  vehicleId: 'v1',
+  vehicleLabel: '2019 Volvo V90',
+  customerName: 'Kari Nordmann',
+  invoiceNumber: '2026-1001',
+  amount: 1200,
+}
+
+const message: CalendarEvent = {
+  id: 'm1',
+  title: 'Service due soon',
+  date: '2026-09-04',
+  time: '08:00',
+  type: 'message',
+  status: 'scheduled',
+  channel: 'sms',
+  vehicleId: null,
+  vehicleLabel: '',
+  customerName: 'Ola Nordmann',
+  invoiceNumber: null,
+  amount: null,
+}
+
+function setup() {
+  const onRefresh = vi.fn()
+  const actions = {
+    onNewWorkOrder: vi.fn(),
+    onNewReminder: vi.fn(),
+    onNewQuote: vi.fn(),
+    onScheduleMessage: vi.fn(),
+  }
+  const onSelectDate = vi.fn()
+  const onOpenDay = vi.fn()
+  const eventsByDate = new Map<string, CalendarEvent[]>([
+    ['2026-09-03', [job]],
+    ['2026-09-04', [message]],
+  ])
+  const utils = render(
+    <EventPeekProvider currencyCode="NOK" onRefresh={onRefresh}>
+      <TimeGridView
+        days={days}
+        eventsByDate={eventsByDate}
+        todayStr="2026-09-06"
+        selectedDateStr="2026-09-06"
+        showWeekends
+        actions={actions}
+        onSelectDate={onSelectDate}
+        onOpenDay={onOpenDay}
+      />
+    </EventPeekProvider>
+  )
+  // The hour columns are the ones carrying half-hour rules.
+  const columns = Array.from(utils.container.querySelectorAll('.relative.border-r')).filter((el) =>
+    el.querySelector('.border-dashed')
+  ) as HTMLElement[]
+  for (const col of columns) {
+    // 24 hours drawn 60px tall each, from the top of the viewport.
+    col.getBoundingClientRect = () =>
+      ({ top: 0, left: 0, width: 100, height: 1440, right: 100, bottom: 1440 }) as DOMRect
+  }
+  return { ...utils, actions, onSelectDate, onOpenDay, onRefresh, columns }
+}
+
+describe('TimeGridView', () => {
+  it('draws seven day columns and the job as a block', () => {
+    const { columns } = setup()
+    expect(columns).toHaveLength(7)
+    expect(screen.getByText('Brake service')).toBeInTheDocument()
+  })
+
+  it('opens the create menu at the clicked quarter hour and hands the time on', async () => {
+    const { columns, actions, onSelectDate } = setup()
+    const tuesday = columns[1]
+    fireEvent.click(tuesday, { clientX: 10, clientY: 620 })
+
+    expect(onSelectDate).toHaveBeenCalledTimes(1)
+    expect(onSelectDate.mock.calls[0][0].getDate()).toBe(1)
+
+    // 620px of 1440 is 10:20, which rounds to the half hour
+    const item = await screen.findByText('New work order at 10:30')
+    expect(screen.getByText('Schedule message')).toBeInTheDocument()
+    fireEvent.click(item)
+    expect(actions.onNewWorkOrder).toHaveBeenCalledWith('2026-09-01', '10:30')
+    await waitFor(() => expect(screen.queryByText('Schedule message')).not.toBeInTheDocument())
+  })
+
+  it('passes the slot time to a scheduled message too', async () => {
+    const { columns, actions } = setup()
+    fireEvent.click(columns[2], { clientX: 10, clientY: 900 })
+    fireEvent.click(await screen.findByText('Schedule message'))
+    expect(actions.onScheduleMessage).toHaveBeenCalledWith('2026-09-02', '15:00')
+  })
+
+  it('peeks at a block instead of creating anything', async () => {
+    const { actions } = setup()
+    fireEvent.click(screen.getByText('Brake service'))
+    const link = await screen.findByRole('link', { name: /Open work order/ })
+    expect(link).toHaveAttribute('href', '/vehicles/v1/service/s1')
+    expect(screen.getByText('Kari Nordmann')).toBeInTheDocument()
+    expect(screen.getByText('1200 NOK')).toBeInTheDocument()
+    expect(actions.onNewWorkOrder).not.toHaveBeenCalled()
+  })
+
+  it('right-click on a scheduled message can delete it after a confirmation', async () => {
+    const { onRefresh, actions } = setup()
+    confirmMock.mockResolvedValue(true)
+    deleteScheduledMessage.mockResolvedValue({ success: true })
+    fireEvent.contextMenu(screen.getByText(/Service due soon/))
+    fireEvent.click(await screen.findByText('Delete scheduled message'))
+    await waitFor(() => expect(deleteScheduledMessage).toHaveBeenCalledWith('m1'))
+    expect(confirmMock).toHaveBeenCalled()
+    await waitFor(() => expect(onRefresh).toHaveBeenCalled())
+    // The day's own menu stayed shut: no "New work order" item appeared.
+    expect(screen.queryByText(/New work order/)).not.toBeInTheDocument()
+    expect(actions.onNewWorkOrder).not.toHaveBeenCalled()
+  })
+
+  it('does nothing when the delete is not confirmed', async () => {
+    setup()
+    confirmMock.mockResolvedValue(false)
+    deleteScheduledMessage.mockClear()
+    fireEvent.contextMenu(screen.getByText(/Service due soon/))
+    fireEvent.click(await screen.findByText('Delete scheduled message'))
+    await waitFor(() => expect(confirmMock).toHaveBeenCalled())
+    expect(deleteScheduledMessage).not.toHaveBeenCalled()
+  })
+
+  it('still offers a scheduled message for a slot that has passed; the dialog says no', async () => {
+    const { columns, actions } = setup()
+    vi.setSystemTime(new Date(2026, 8, 2, 16, 0)) // 2 Sep, 16:00
+    fireEvent.click(columns[2], { clientX: 10, clientY: 600 }) // 2 Sep, 10:00
+    fireEvent.click(await screen.findByText('Schedule message'))
+    expect(actions.onScheduleMessage).toHaveBeenCalledWith('2026-09-02', '10:00')
+    vi.setSystemTime(new Date(2026, 7, 15, 12, 0))
+  })
+
+  it('offers the same choices on right-click, with the slot time', async () => {
+    const { columns, actions } = setup()
+    fireEvent.contextMenu(columns[5], { clientX: 10, clientY: 840 })
+    const item = await screen.findByText('New work order at 14:00')
+    fireEvent.click(item)
+    expect(actions.onNewWorkOrder).toHaveBeenCalledWith('2026-09-05', '14:00')
+  })
+})

+ 109 - 109
src/__tests__/features/integrations/fixtures/nhtsa/complaints-accord-2003.json

@@ -1,116 +1,116 @@
 {
- "count": 5,
- "message": "Results returned successfully",
- "results": [
-  {
-   "odiNumber": 11746949,
-   "manufacturer": "Honda (American Honda Motor Co.)",
-   "crash": true,
-   "fire": false,
-   "numberOfInjuries": 1,
-   "numberOfDeaths": 0,
-   "dateOfIncident": "06/18/2026",
-   "dateComplaintFiled": "06/27/2026",
-   "vin": "JHMCM56323C",
-   "components": "AIR BAGS",
-   "summary": "The vehicle was rear-ended on I-5 in Seattle Washington.  The impact drove the subject car into the car in front of it. The front passenger airbag deployed (no ",
-   "products": [
+  "count": 5,
+  "message": "Results returned successfully",
+  "results": [
     {
-     "type": "Vehicle",
-     "productYear": "2003",
-     "productMake": "HONDA",
-     "productModel": "ACCORD",
-     "manufacturer": "Honda (American Honda Motor Co.)"
-    }
-   ]
-  },
-  {
-   "odiNumber": 11635240,
-   "manufacturer": "Honda (American Honda Motor Co.)",
-   "crash": true,
-   "fire": false,
-   "numberOfInjuries": 1,
-   "numberOfDeaths": 0,
-   "dateOfIncident": "12/12/2024",
-   "dateComplaintFiled": "01/10/2025",
-   "vin": "1HGCM81623A",
-   "components": "AIR BAGS",
-   "summary": "My 2003 Honda Accord Coupe was struck by an SUV/crossover, after the other driver ran a red light, and I tried, but was unable to avoid the accident.  My car wa",
-   "products": [
+      "odiNumber": 11746949,
+      "manufacturer": "Honda (American Honda Motor Co.)",
+      "crash": true,
+      "fire": false,
+      "numberOfInjuries": 1,
+      "numberOfDeaths": 0,
+      "dateOfIncident": "06/18/2026",
+      "dateComplaintFiled": "06/27/2026",
+      "vin": "JHMCM56323C",
+      "components": "AIR BAGS",
+      "summary": "The vehicle was rear-ended on I-5 in Seattle Washington.  The impact drove the subject car into the car in front of it. The front passenger airbag deployed (no ",
+      "products": [
+        {
+          "type": "Vehicle",
+          "productYear": "2003",
+          "productMake": "HONDA",
+          "productModel": "ACCORD",
+          "manufacturer": "Honda (American Honda Motor Co.)"
+        }
+      ]
+    },
     {
-     "type": "Vehicle",
-     "productYear": "2003",
-     "productMake": "HONDA",
-     "productModel": "ACCORD",
-     "manufacturer": "Honda (American Honda Motor Co.)"
-    }
-   ]
-  },
-  {
-   "odiNumber": 11746949,
-   "manufacturer": "Honda (American Honda Motor Co.)",
-   "crash": true,
-   "fire": false,
-   "numberOfInjuries": 1,
-   "numberOfDeaths": 0,
-   "dateOfIncident": "06/18/2026",
-   "dateComplaintFiled": "06/27/2026",
-   "vin": "JHMCM56323C",
-   "components": "AIR BAGS",
-   "summary": "The vehicle was rear-ended on I-5 in Seattle Washington.  The impact drove the subject car into the car in front of it. The front passenger airbag deployed (no ",
-   "products": [
+      "odiNumber": 11635240,
+      "manufacturer": "Honda (American Honda Motor Co.)",
+      "crash": true,
+      "fire": false,
+      "numberOfInjuries": 1,
+      "numberOfDeaths": 0,
+      "dateOfIncident": "12/12/2024",
+      "dateComplaintFiled": "01/10/2025",
+      "vin": "1HGCM81623A",
+      "components": "AIR BAGS",
+      "summary": "My 2003 Honda Accord Coupe was struck by an SUV/crossover, after the other driver ran a red light, and I tried, but was unable to avoid the accident.  My car wa",
+      "products": [
+        {
+          "type": "Vehicle",
+          "productYear": "2003",
+          "productMake": "HONDA",
+          "productModel": "ACCORD",
+          "manufacturer": "Honda (American Honda Motor Co.)"
+        }
+      ]
+    },
     {
-     "type": "Vehicle",
-     "productYear": "2003",
-     "productMake": "HONDA",
-     "productModel": "ACCORD",
-     "manufacturer": "Honda (American Honda Motor Co.)"
-    }
-   ]
-  },
-  {
-   "odiNumber": 11740104,
-   "manufacturer": "Honda (American Honda Motor Co.)",
-   "crash": false,
-   "fire": false,
-   "numberOfInjuries": 0,
-   "numberOfDeaths": 0,
-   "dateOfIncident": "05/22/2026",
-   "dateComplaintFiled": "05/26/2026",
-   "vin": "1HGCM66533A",
-   "components": "AIR BAGS",
-   "summary": "The contact owns a 2003 Honda Accord. The contact stated while attempting to start the vehicle, the contact noticed an unusual sound and the front passenger's s",
-   "products": [
+      "odiNumber": 11746949,
+      "manufacturer": "Honda (American Honda Motor Co.)",
+      "crash": true,
+      "fire": false,
+      "numberOfInjuries": 1,
+      "numberOfDeaths": 0,
+      "dateOfIncident": "06/18/2026",
+      "dateComplaintFiled": "06/27/2026",
+      "vin": "JHMCM56323C",
+      "components": "AIR BAGS",
+      "summary": "The vehicle was rear-ended on I-5 in Seattle Washington.  The impact drove the subject car into the car in front of it. The front passenger airbag deployed (no ",
+      "products": [
+        {
+          "type": "Vehicle",
+          "productYear": "2003",
+          "productMake": "HONDA",
+          "productModel": "ACCORD",
+          "manufacturer": "Honda (American Honda Motor Co.)"
+        }
+      ]
+    },
     {
-     "type": "Vehicle",
-     "productYear": "2003",
-     "productMake": "HONDA",
-     "productModel": "ACCORD",
-     "manufacturer": "Honda (American Honda Motor Co.)"
-    }
-   ]
-  },
-  {
-   "odiNumber": 11729508,
-   "manufacturer": "Honda (American Honda Motor Co.)",
-   "crash": false,
-   "fire": false,
-   "numberOfInjuries": 0,
-   "numberOfDeaths": 0,
-   "dateOfIncident": "04/02/2026",
-   "dateComplaintFiled": "04/06/2026",
-   "vin": "JHMCM56343C",
-   "components": "ELECTRICAL SYSTEM,AIR BAGS,UNKNOWN OR OTHER",
-   "summary": "I brought my vehicle to a Honda dealership for a safety recall repair (airbag/SRS related). After the repair, the SRS warning light appeared on my dashboard, in",
-   "products": [
+      "odiNumber": 11740104,
+      "manufacturer": "Honda (American Honda Motor Co.)",
+      "crash": false,
+      "fire": false,
+      "numberOfInjuries": 0,
+      "numberOfDeaths": 0,
+      "dateOfIncident": "05/22/2026",
+      "dateComplaintFiled": "05/26/2026",
+      "vin": "1HGCM66533A",
+      "components": "AIR BAGS",
+      "summary": "The contact owns a 2003 Honda Accord. The contact stated while attempting to start the vehicle, the contact noticed an unusual sound and the front passenger's s",
+      "products": [
+        {
+          "type": "Vehicle",
+          "productYear": "2003",
+          "productMake": "HONDA",
+          "productModel": "ACCORD",
+          "manufacturer": "Honda (American Honda Motor Co.)"
+        }
+      ]
+    },
     {
-     "type": "Vehicle",
-     "productYear": "2003",
-     "productMake": "HONDA",
-     "productModel": "ACCORD",
-     "manufacturer": "Honda (American Honda Motor Co.)"
+      "odiNumber": 11729508,
+      "manufacturer": "Honda (American Honda Motor Co.)",
+      "crash": false,
+      "fire": false,
+      "numberOfInjuries": 0,
+      "numberOfDeaths": 0,
+      "dateOfIncident": "04/02/2026",
+      "dateComplaintFiled": "04/06/2026",
+      "vin": "JHMCM56343C",
+      "components": "ELECTRICAL SYSTEM,AIR BAGS,UNKNOWN OR OTHER",
+      "summary": "I brought my vehicle to a Honda dealership for a safety recall repair (airbag/SRS related). After the repair, the SRS warning light appeared on my dashboard, in",
+      "products": [
+        {
+          "type": "Vehicle",
+          "productYear": "2003",
+          "productMake": "HONDA",
+          "productModel": "ACCORD",
+          "manufacturer": "Honda (American Honda Motor Co.)"
+        }
+      ]
     }
-   ]
-  }
- ]
-}
+  ]
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
src/__tests__/features/integrations/fixtures/nhtsa/decode-1HGCM82633A004352.json


+ 175 - 175
src/__tests__/features/integrations/fixtures/nhtsa/models-honda-2003.json

@@ -1,176 +1,176 @@
 {
- "count": 34,
- "message": "Results returned successfully",
- "results": [
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "CR-V"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "CIVIC"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "ODYSSEY"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "PILOT"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "ELEMENT"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "S2000"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "INSIGHT"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "ACCORD"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "CBR600RR"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "FSC600"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "SILVER WING"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "HONDA MOTORCYCLE"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "CB900F"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "VTX1800"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "V SERIES"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "GL1800"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "VTX1300"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "VT1100"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "VT750CD"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "GL1800A"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "RVT1000R"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "CBR1100XX"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "ST1300A"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "VFR800"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "VFR800A"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "GL1500C"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "NSS250"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "VFR800FI"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "ST1300"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "TRX650"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "CB250"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "CBR954RR"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "VT600C"
-  },
-  {
-   "modelYear": "2003",
-   "make": "HONDA",
-   "model": "CIVIC GX"
-  }
- ]
-}
+  "count": 34,
+  "message": "Results returned successfully",
+  "results": [
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "CR-V"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "CIVIC"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "ODYSSEY"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "PILOT"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "ELEMENT"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "S2000"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "INSIGHT"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "ACCORD"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "CBR600RR"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "FSC600"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "SILVER WING"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "HONDA MOTORCYCLE"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "CB900F"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "VTX1800"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "V SERIES"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "GL1800"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "VTX1300"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "VT1100"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "VT750CD"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "GL1800A"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "RVT1000R"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "CBR1100XX"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "ST1300A"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "VFR800"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "VFR800A"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "GL1500C"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "NSS250"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "VFR800FI"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "ST1300"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "TRX650"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "CB250"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "CBR954RR"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "VT600C"
+    },
+    {
+      "modelYear": "2003",
+      "make": "HONDA",
+      "model": "CIVIC GX"
+    }
+  ]
+}

+ 37 - 1
src/__tests__/features/integrations/fixtures/nhtsa/rating-4738.json

@@ -1 +1,37 @@
-{"Count":1,"Message":"Results returned successfully","Results":[{"OverallRating":"Not Rated","OverallFrontCrashRating":"Not Rated","FrontCrashDriversideRating":"5","FrontCrashPassengersideRating":"5","FrontCrashPicture":"https://static.nhtsa.gov/crashTest/images/2003/03Accord4.jpg","FrontCrashVideo":"https://static.nhtsa.gov/crashTest/videos/2003/03Accord4.wmv","OverallSideCrashRating":"Not Rated","SideCrashDriversideRating":"Not Rated","SideCrashPassengersideRating":"Not Rated","combinedSideBarrierAndPoleRating-Front":"Not Rated","combinedSideBarrierAndPoleRating-Rear":"Not Rated","sideBarrierRating-Overall":"Not Rated","RolloverRating":"Not Rated","RolloverRating2":"4","RolloverPossibility":0.0,"RolloverPossibility2":0.0,"dynamicTipResult":" ","SidePoleCrashRating":"Not Rated","NHTSAElectronicStabilityControl":"No","NHTSAForwardCollisionWarning":"No","NHTSALaneDepartureWarning":"No","ComplaintsCount":2013,"RecallsCount":24,"InvestigationCount":5,"ModelYear":2003,"Make":"HONDA","Model":"ACCORD","VehicleDescription":"2003 Honda Accord 4-DR. w/SAB","VehicleId":4738}]}
+{
+  "Count": 1,
+  "Message": "Results returned successfully",
+  "Results": [
+    {
+      "OverallRating": "Not Rated",
+      "OverallFrontCrashRating": "Not Rated",
+      "FrontCrashDriversideRating": "5",
+      "FrontCrashPassengersideRating": "5",
+      "FrontCrashPicture": "https://static.nhtsa.gov/crashTest/images/2003/03Accord4.jpg",
+      "FrontCrashVideo": "https://static.nhtsa.gov/crashTest/videos/2003/03Accord4.wmv",
+      "OverallSideCrashRating": "Not Rated",
+      "SideCrashDriversideRating": "Not Rated",
+      "SideCrashPassengersideRating": "Not Rated",
+      "combinedSideBarrierAndPoleRating-Front": "Not Rated",
+      "combinedSideBarrierAndPoleRating-Rear": "Not Rated",
+      "sideBarrierRating-Overall": "Not Rated",
+      "RolloverRating": "Not Rated",
+      "RolloverRating2": "4",
+      "RolloverPossibility": 0.0,
+      "RolloverPossibility2": 0.0,
+      "dynamicTipResult": " ",
+      "SidePoleCrashRating": "Not Rated",
+      "NHTSAElectronicStabilityControl": "No",
+      "NHTSAForwardCollisionWarning": "No",
+      "NHTSALaneDepartureWarning": "No",
+      "ComplaintsCount": 2013,
+      "RecallsCount": 24,
+      "InvestigationCount": 5,
+      "ModelYear": 2003,
+      "Make": "HONDA",
+      "Model": "ACCORD",
+      "VehicleDescription": "2003 Honda Accord 4-DR. w/SAB",
+      "VehicleId": 4738
+    }
+  ]
+}

+ 10 - 1
src/__tests__/features/integrations/fixtures/nhtsa/ratings-accord-2003.json

@@ -1 +1,10 @@
-{"Count":4,"Message":"Results returned successfully","Results":[{"VehicleDescription":"2003 Honda Accord 2-DR. w/SAB","VehicleId":4739},{"VehicleDescription":"2003 Honda Accord 4-DR. w/SAB","VehicleId":4738},{"VehicleDescription":"2003 Honda Accord 4-DR.","VehicleId":4737},{"VehicleDescription":"2003 Honda Accord 2-DR.","VehicleId":4736}]}
+{
+  "Count": 4,
+  "Message": "Results returned successfully",
+  "Results": [
+    { "VehicleDescription": "2003 Honda Accord 2-DR. w/SAB", "VehicleId": 4739 },
+    { "VehicleDescription": "2003 Honda Accord 4-DR. w/SAB", "VehicleId": 4738 },
+    { "VehicleDescription": "2003 Honda Accord 4-DR.", "VehicleId": 4737 },
+    { "VehicleDescription": "2003 Honda Accord 2-DR.", "VehicleId": 4736 }
+  ]
+}

+ 52 - 52
src/__tests__/features/integrations/fixtures/nhtsa/recalls-accord-2003.json

@@ -1,53 +1,53 @@
 {
- "Count": 3,
- "Message": "Results returned successfully",
- "results": [
-  {
-   "Manufacturer": "Honda (American Honda Motor Co.)",
-   "NHTSACampaignNumber": "19V182000",
-   "parkIt": false,
-   "parkOutSide": false,
-   "overTheAirUpdate": false,
-   "NHTSAActionNumber": "EA15001",
-   "ReportReceivedDate": "06/03/2019",
-   "Component": "AIR BAGS:FRONTAL:DRIVER SIDE:INFLATOR MODULE",
-   "Summary": "Honda (American Honda Motor Co.) is recalling specific 2003 Acura 3.2CL, 2013-2016 ILX, 2013-2014 ILX Hybrid, 2003-2006 MDX, 2007-2016 RDX, 2002-2003 3.2TL, 2004-2006, and 2009-2014 TL, 2010-2013 ZDX and 2001-2007 and 2009 Honda Accord, 2001-2005 Civic, 2003-2005 Civic Hybrid, 2001-2005 Civic GX NGV, 2002-2007 and 2010-2011 CR-V, 2003-2011 Element, 2007 Fit, 2002-2004 Odyssey, 2003-2008 Pilot, and 2006-2014 Ridgeline vehicles.  The affected vehicles received a replacement driver air bag inflator as part of a previous Takata inflator recall remedy or a replacement driver air bag module containing the same inflator type as a service part.  Due to a manufacturing error, in the event of a crash necessitating deployment of the driver frontal air bag, these inflators may explode.",
-   "Consequence": "An explosion of an inflator within the driver frontal air bag module may result in sharp metal fragments striking the driver, front seat passenger or other occupants resulting in serious injury or death.",
-   "Remedy": "Honda will notify owners, and dealers will replace the driver's air bag inflator with an alternate inflator, free of charge.  The recall began April 10, 2019.  Honda owners may contact customer service at 1-888-234-2138.  Honda's number for this recall is O41.  Acura owners may contact customer service at 1-888-234-2138.  Acura's number for this recall is U40.",
-   "Notes": "Owners may also contact the National Highway Traffic Safety Administration Vehicle Safety Hotline at 1-888-327-4236 (TTY 1-800-424-9153), or go to www.safercar.gov.",
-   "ModelYear": "2003",
-   "Make": "HONDA",
-   "Model": "ACCORD"
-  },
-  {
-   "Manufacturer": "Dorman Products, Inc.",
-   "NHTSACampaignNumber": "19E068000",
-   "ReportReceivedDate": "10/10/2019",
-   "Component": "VEHICLE SPEED CONTROL:ACCELERATOR PEDAL",
-   "Summary": "Dorman Products, Inc.  (Dorman) is recalling certain Accelerator Pedal Assemblies part numbers 699-114 and 825-5029-1, sold as replacement parts for 2003-2006 Acura MDX, 2004-2008 Acura TL and TSX, 2003-2007 Honda Accord, 2005-2006 Honda CR-V, 2007-2011 Honda Element, 2005-2008 Honda Pilot and 2006-2014 Honda Ridgeline vehicles.  The rotating portion of the accelerator pedal assembly may bind. As such, these vehicles fail to comply with the requirements of Federal Motor Vehicle Safety Standard (FMVSS) number 124, \"Accelerator Control Systems.\"",
-   "Consequence": "If the accelerator pedal binds, the engine may not quickly return to idle after the pedal is no longer pressed, increasing the risk of a crash.",
-   "Remedy": "Dorman will notify owners, and qualified service facilities will replace the affected accelerator pedal assemblies, free of charge.  The recall began December 9, 2019.  Owners may contact Dorman customer service at 1-800-523-2492, using option 5.  Dorman's number for this recall is AS1019XXXX.",
-   "Notes": "Owners may also contact the National Highway Traffic Safety Administration Vehicle Safety Hotline at 1-888-327-4236 (TTY 1-800-424-9153), or go to www.safercar.gov.",
-   "ModelYear": "2003",
-   "Make": "HONDA",
-   "Model": "ACCORD"
-  },
-  {
-   "Manufacturer": "Honda (American Honda Motor Co.)",
-   "NHTSACampaignNumber": "19V499000",
-   "parkIt": false,
-   "parkOutSide": false,
-   "overTheAirUpdate": false,
-   "NHTSAActionNumber": "EA15001",
-   "ReportReceivedDate": "27/06/2019",
-   "Component": "AIR BAGS:FRONTAL:DRIVER SIDE:INFLATOR MODULE",
-   "Summary": "Honda (American Honda Motor Co.) is recalling certain 2003 Acura 3.2CL, 2002-2003 3.2TL, 2003-2006 MDX, 2001-2007 Honda Accord, 2001-2005 Civic, 2003-2005 Civic Hybrid, 2001-2005 Civic GX NGV, 2002-2006 CR-V, 2003-2011 Element, 2002-2004 Odyssey, 2003-2008 Pilot and 2006 Ridgeline vehicles.  \r\n\r\nThese vehicles are equipped with driver frontal air bag inflators assembled as a recall remedy part or replacement service part, that may explode due to propellant degradation occurring after long-term exposure to high absolute humidity, temperature and temperature cycling.",
-   "Consequence": "An inflator explosion may result in sharp metal fragments striking the driver or other occupants resulting in serious injury or death.",
-   "Remedy": "Honda will notify owners, and dealers will replace the driver frontal air bag inflator, free of charge.  The recall began August 12, 2019.  Owners may contact Honda customer service at 1-888-234-2138.  Honda's numbers for this recall are Q5A and Y58.",
-   "Notes": "Owners may also contact the National Highway Traffic Safety Administration Vehicle Safety Hotline at 1-888-327-4236 (TTY 1-800-424-9153), or go to www.safercar.gov.",
-   "ModelYear": "2003",
-   "Make": "HONDA",
-   "Model": "ACCORD"
-  }
- ]
-}
+  "Count": 3,
+  "Message": "Results returned successfully",
+  "results": [
+    {
+      "Manufacturer": "Honda (American Honda Motor Co.)",
+      "NHTSACampaignNumber": "19V182000",
+      "parkIt": false,
+      "parkOutSide": false,
+      "overTheAirUpdate": false,
+      "NHTSAActionNumber": "EA15001",
+      "ReportReceivedDate": "06/03/2019",
+      "Component": "AIR BAGS:FRONTAL:DRIVER SIDE:INFLATOR MODULE",
+      "Summary": "Honda (American Honda Motor Co.) is recalling specific 2003 Acura 3.2CL, 2013-2016 ILX, 2013-2014 ILX Hybrid, 2003-2006 MDX, 2007-2016 RDX, 2002-2003 3.2TL, 2004-2006, and 2009-2014 TL, 2010-2013 ZDX and 2001-2007 and 2009 Honda Accord, 2001-2005 Civic, 2003-2005 Civic Hybrid, 2001-2005 Civic GX NGV, 2002-2007 and 2010-2011 CR-V, 2003-2011 Element, 2007 Fit, 2002-2004 Odyssey, 2003-2008 Pilot, and 2006-2014 Ridgeline vehicles.  The affected vehicles received a replacement driver air bag inflator as part of a previous Takata inflator recall remedy or a replacement driver air bag module containing the same inflator type as a service part.  Due to a manufacturing error, in the event of a crash necessitating deployment of the driver frontal air bag, these inflators may explode.",
+      "Consequence": "An explosion of an inflator within the driver frontal air bag module may result in sharp metal fragments striking the driver, front seat passenger or other occupants resulting in serious injury or death.",
+      "Remedy": "Honda will notify owners, and dealers will replace the driver's air bag inflator with an alternate inflator, free of charge.  The recall began April 10, 2019.  Honda owners may contact customer service at 1-888-234-2138.  Honda's number for this recall is O41.  Acura owners may contact customer service at 1-888-234-2138.  Acura's number for this recall is U40.",
+      "Notes": "Owners may also contact the National Highway Traffic Safety Administration Vehicle Safety Hotline at 1-888-327-4236 (TTY 1-800-424-9153), or go to www.safercar.gov.",
+      "ModelYear": "2003",
+      "Make": "HONDA",
+      "Model": "ACCORD"
+    },
+    {
+      "Manufacturer": "Dorman Products, Inc.",
+      "NHTSACampaignNumber": "19E068000",
+      "ReportReceivedDate": "10/10/2019",
+      "Component": "VEHICLE SPEED CONTROL:ACCELERATOR PEDAL",
+      "Summary": "Dorman Products, Inc.  (Dorman) is recalling certain Accelerator Pedal Assemblies part numbers 699-114 and 825-5029-1, sold as replacement parts for 2003-2006 Acura MDX, 2004-2008 Acura TL and TSX, 2003-2007 Honda Accord, 2005-2006 Honda CR-V, 2007-2011 Honda Element, 2005-2008 Honda Pilot and 2006-2014 Honda Ridgeline vehicles.  The rotating portion of the accelerator pedal assembly may bind. As such, these vehicles fail to comply with the requirements of Federal Motor Vehicle Safety Standard (FMVSS) number 124, \"Accelerator Control Systems.\"",
+      "Consequence": "If the accelerator pedal binds, the engine may not quickly return to idle after the pedal is no longer pressed, increasing the risk of a crash.",
+      "Remedy": "Dorman will notify owners, and qualified service facilities will replace the affected accelerator pedal assemblies, free of charge.  The recall began December 9, 2019.  Owners may contact Dorman customer service at 1-800-523-2492, using option 5.  Dorman's number for this recall is AS1019XXXX.",
+      "Notes": "Owners may also contact the National Highway Traffic Safety Administration Vehicle Safety Hotline at 1-888-327-4236 (TTY 1-800-424-9153), or go to www.safercar.gov.",
+      "ModelYear": "2003",
+      "Make": "HONDA",
+      "Model": "ACCORD"
+    },
+    {
+      "Manufacturer": "Honda (American Honda Motor Co.)",
+      "NHTSACampaignNumber": "19V499000",
+      "parkIt": false,
+      "parkOutSide": false,
+      "overTheAirUpdate": false,
+      "NHTSAActionNumber": "EA15001",
+      "ReportReceivedDate": "27/06/2019",
+      "Component": "AIR BAGS:FRONTAL:DRIVER SIDE:INFLATOR MODULE",
+      "Summary": "Honda (American Honda Motor Co.) is recalling certain 2003 Acura 3.2CL, 2002-2003 3.2TL, 2003-2006 MDX, 2001-2007 Honda Accord, 2001-2005 Civic, 2003-2005 Civic Hybrid, 2001-2005 Civic GX NGV, 2002-2006 CR-V, 2003-2011 Element, 2002-2004 Odyssey, 2003-2008 Pilot and 2006 Ridgeline vehicles.  \r\n\r\nThese vehicles are equipped with driver frontal air bag inflators assembled as a recall remedy part or replacement service part, that may explode due to propellant degradation occurring after long-term exposure to high absolute humidity, temperature and temperature cycling.",
+      "Consequence": "An inflator explosion may result in sharp metal fragments striking the driver or other occupants resulting in serious injury or death.",
+      "Remedy": "Honda will notify owners, and dealers will replace the driver frontal air bag inflator, free of charge.  The recall began August 12, 2019.  Owners may contact Honda customer service at 1-888-234-2138.  Honda's numbers for this recall are Q5A and Y58.",
+      "Notes": "Owners may also contact the National Highway Traffic Safety Administration Vehicle Safety Hotline at 1-888-327-4236 (TTY 1-800-424-9153), or go to www.safercar.gov.",
+      "ModelYear": "2003",
+      "Make": "HONDA",
+      "Model": "ACCORD"
+    }
+  ]
+}

+ 86 - 0
src/__tests__/features/invoices/recurring-invoice-timezone.test.ts

@@ -0,0 +1,86 @@
+/**
+ * Recurring invoices read their dates as workshop days and step on the
+ * workshop's clock, whatever zone the server runs in.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn() }))
+// A plain function, so a resetAllMocks in a beforeEach cannot blank it.
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: async () => 'Europe/Oslo' }))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    vehicle: { findFirst: vi.fn() },
+    appSetting: { findUnique: vi.fn() },
+    recurringInvoice: { create: vi.fn() },
+  },
+}))
+
+import { db } from '@/lib/db'
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { calculateNextRunDate } from '@/lib/cron/recurring-invoices'
+import { createRecurringInvoice } from '@/features/billing/Actions/recurringInvoiceActions'
+
+function setupAuth() {
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-1', email: 'user@example.com' },
+  } as any)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: 'org-1',
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+})
+
+describe('calculateNextRunDate', () => {
+  it('keeps workshop midnight across the October DST change', () => {
+    // 1 Oct 2026 00:00 CEST is 30 Sep 22:00Z; 1 Nov 00:00 CET is 31 Oct 23:00Z
+    const oct1 = new Date('2026-09-30T22:00:00Z')
+    expect(calculateNextRunDate(oct1, 'monthly', 'Europe/Oslo').toISOString()).toBe(
+      '2026-10-31T23:00:00.000Z'
+    )
+    expect(calculateNextRunDate(oct1, 'weekly', 'Europe/Oslo').toISOString()).toBe(
+      '2026-10-07T22:00:00.000Z'
+    )
+    expect(calculateNextRunDate(oct1, 'quarterly', 'Europe/Oslo').toISOString()).toBe(
+      '2026-12-31T23:00:00.000Z'
+    )
+  })
+})
+
+describe('createRecurringInvoice', () => {
+  it('reads the start and end days on the workshop clock', async () => {
+    setupAuth()
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ id: 'veh-1' } as any)
+    vi.mocked(db.recurringInvoice.create).mockImplementation((async ({ data }: any) => ({
+      id: 'ri-1',
+      ...data,
+    })) as any)
+
+    const result = await createRecurringInvoice({
+      title: 'Storage',
+      frequency: 'monthly',
+      nextRunDate: '2026-10-01',
+      endDate: '2026-12-31',
+      vehicleId: 'veh-1',
+      taxInclusive: false,
+    } as any)
+
+    expect(result.success).toBe(true)
+    const data = vi.mocked(db.recurringInvoice.create).mock.calls[0][0].data
+    expect(data.nextRunDate).toEqual(new Date('2026-09-30T22:00:00.000Z'))
+    expect(data.endDate).toEqual(new Date('2026-12-31T22:59:59.999Z'))
+  })
+})

+ 148 - 0
src/__tests__/features/messaging/scheduled-message-timezone.test.ts

@@ -0,0 +1,148 @@
+/**
+ * Scheduled messages keep the workshop's clock, not the server's.
+ *
+ * The server runs in UTC. A repeating message must roll forward on the
+ * workshop's wall clock (18:00 stays 18:00 across a DST change), and the
+ * calendar's day window must cover whole workshop days.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn() }))
+vi.mock('@/lib/notify', () => ({ notify: vi.fn() }))
+vi.mock('@/lib/email', () => ({ sendOrgMail: vi.fn(), getOrgFromAddress: vi.fn() }))
+vi.mock('@/lib/sms', () => ({
+  sendOrgSms: vi.fn(),
+  getOrgSmsPhoneNumber: vi.fn(),
+  normalizeOrgPhone: vi.fn(),
+}))
+vi.mock('@/lib/telegram', () => ({ sendTelegramMessage: vi.fn() }))
+vi.mock('@/lib/whatsapp', () => ({ sendOrgWhatsapp: vi.fn() }))
+
+// Plain functions, so a resetAllMocks in a beforeEach cannot blank them.
+const zoneLookups: string[] = []
+vi.mock('@/lib/workshop-timezone', () => ({
+  workshopTimeZone: async (organizationId: string) => {
+    zoneLookups.push(organizationId)
+    return organizationId === 'org-oslo' ? 'Europe/Oslo' : 'UTC'
+  },
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    customer: { findFirst: vi.fn() },
+    scheduledMessage: { findMany: vi.fn(), update: vi.fn(), create: vi.fn() },
+  },
+}))
+
+import { db } from '@/lib/db'
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { nextSendAt } from '@/features/scheduled-messages/Lib/dispatchScheduledMessage'
+import { processDueMessages } from '@/lib/cron/scheduled-messages'
+import {
+  createScheduledMessage,
+  getScheduledMessagesInRange,
+} from '@/features/scheduled-messages/Actions/scheduledMessageActions'
+
+function setupAuth(organizationId: string) {
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-1', email: 'user@example.com' },
+  } as any)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  zoneLookups.length = 0
+})
+
+describe('nextSendAt', () => {
+  it('keeps a daily 18:00 Oslo message at 18:00 across the October DST change', () => {
+    // 24 Oct 2026 18:00 CEST is 16:00Z; the clocks go back overnight
+    const before = new Date('2026-10-24T16:00:00Z')
+    const next = nextSendAt(before, 'daily', null, 'Europe/Oslo')
+    expect(next?.toISOString()).toBe('2026-10-25T17:00:00.000Z')
+    // The server's own zone would have kept 16:00Z, which is 17:00 in Oslo
+    expect(nextSendAt(before, 'daily', null, 'UTC')?.toISOString()).toBe('2026-10-25T16:00:00.000Z')
+  })
+
+  it('stops once the next slot is past the end date', () => {
+    const start = new Date('2026-10-24T16:00:00Z')
+    expect(nextSendAt(start, 'weekly', new Date('2026-10-30T00:00:00Z'), 'Europe/Oslo')).toBeNull()
+    expect(nextSendAt(start, 'once', null, 'Europe/Oslo')).toBeNull()
+  })
+})
+
+describe('processDueMessages', () => {
+  it('rolls a repeating message forward on its workshop clock, one zone lookup per workshop', async () => {
+    const base = {
+      channel: 'in_app',
+      subject: 'Reminder',
+      body: 'Time for a service',
+      recipient: null,
+      organizationId: 'org-oslo',
+      customerId: null,
+      vehicleId: null,
+      frequency: 'daily',
+      endDate: null,
+      runCount: 0,
+    }
+    vi.mocked(db.scheduledMessage.findMany).mockResolvedValue([
+      { ...base, id: 'msg-1', sendAt: new Date('2026-10-24T16:00:00Z') },
+      { ...base, id: 'msg-2', sendAt: new Date('2026-10-24T16:00:00Z') },
+    ] as any)
+    vi.mocked(db.scheduledMessage.update).mockResolvedValue({} as any)
+
+    const sent = await processDueMessages(new Date('2026-10-24T16:00:30Z'))
+
+    expect(sent).toBe(2)
+    const updates = vi.mocked(db.scheduledMessage.update).mock.calls.map((c) => c[0].data)
+    expect(updates[0].sendAt).toEqual(new Date('2026-10-25T17:00:00.000Z'))
+    expect(updates[1].sendAt).toEqual(new Date('2026-10-25T17:00:00.000Z'))
+    expect(zoneLookups).toEqual(['org-oslo'])
+  })
+})
+
+describe('getScheduledMessagesInRange', () => {
+  it('turns the day keys into a window of whole workshop days', async () => {
+    setupAuth('org-oslo')
+    vi.mocked(db.scheduledMessage.findMany).mockResolvedValue([] as any)
+
+    const result = await getScheduledMessagesInRange({ start: '2026-09-01', end: '2026-09-30' })
+
+    expect(result.success).toBe(true)
+    const where = vi.mocked(db.scheduledMessage.findMany).mock.calls[0][0]?.where
+    expect(where?.sendAt).toEqual({
+      gte: new Date('2026-08-31T22:00:00.000Z'),
+      lt: new Date('2026-09-30T22:00:00.000Z'),
+    })
+  })
+})
+
+describe('createScheduledMessage', () => {
+  it('refuses a send time that has already passed', async () => {
+    setupAuth('org-oslo')
+    vi.mocked(db.scheduledMessage.create).mockResolvedValue({ id: 'm-1' } as any)
+    const result = await createScheduledMessage({
+      channel: 'sms',
+      body: 'Your car is ready',
+      recipient: '+4799999999',
+      sendAt: '2020-01-01T09:00',
+      frequency: 'once',
+    })
+    expect(result.success).toBe(false)
+    expect(result.error).toMatch(/already passed/)
+    expect(vi.mocked(db.scheduledMessage.create)).not.toHaveBeenCalled()
+  })
+})

+ 3 - 0
src/__tests__/features/reminder-alerts.test.ts

@@ -9,6 +9,9 @@ vi.mock('@/lib/cached-session', () => ({
   getCachedMembership: vi.fn(),
 }))
 vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+// The workshop zone is UTC here, so a bare day is that day's UTC midnight.
+// A plain function, so a resetAllMocks in a beforeEach cannot blank it.
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: async () => 'UTC' }))
 vi.mock('@/lib/notify', () => ({ notify: vi.fn() }))
 vi.mock('@/lib/email', () => ({
   sendOrgMail: vi.fn(),

+ 140 - 0
src/__tests__/features/reports/report-schedule-timezone.test.ts

@@ -0,0 +1,140 @@
+/**
+ * Report schedules run at 08:00 on the workshop's clock and report over
+ * whole workshop days, whatever zone the server runs in.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn() }))
+vi.mock('@/lib/email', () => ({
+  sendOrgMail: vi.fn(),
+  getOrgFromAddress: vi.fn(async () => 'Shop <shop@example.com>'),
+}))
+// The PDF side is not under test: keep the element so its props can be read.
+vi.mock('@react-pdf/renderer', () => ({
+  renderToBuffer: vi.fn(async () => Buffer.from('pdf')),
+}))
+vi.mock('@/features/vehicles/Components/invoice-pdf/fonts', () => ({}))
+vi.mock('@/features/reports/Components/ReportPDF', () => ({ ReportPDF: () => null }))
+// A plain function, so a resetAllMocks in a beforeEach cannot blank it.
+vi.mock('@/lib/workshop-timezone', () => ({
+  workshopTimeZone: async () => 'America/Chicago',
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn(), findMany: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+    serviceRecord: { findMany: vi.fn() },
+    organization: { findUnique: vi.fn() },
+    reportSchedule: { create: vi.fn(), update: vi.fn() },
+  },
+}))
+
+import { renderToBuffer } from '@react-pdf/renderer'
+import { db } from '@/lib/db'
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { createReportSchedule } from '@/features/report-schedule/Actions/reportScheduleActions'
+import { processOneSchedule } from '@/lib/cron/report-schedules'
+
+function setupAuth() {
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-1', email: 'user@example.com' },
+  } as any)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: 'org-1',
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  // 1 Jul 2026 12:00Z is 07:00 CDT in Chicago
+  vi.useFakeTimers({ now: new Date('2026-07-01T12:00:00Z'), toFake: ['Date'] })
+})
+
+afterEach(() => {
+  vi.useRealTimers()
+})
+
+describe('createReportSchedule', () => {
+  it('sets the next run to 08:00 in the workshop and the end date to the end of its day', async () => {
+    setupAuth()
+    vi.mocked(db.reportSchedule.create).mockImplementation((async ({ data }: any) => ({
+      id: 'sched-1',
+      ...data,
+    })) as any)
+
+    const result = await createReportSchedule({
+      frequency: 'weekly',
+      sections: ['revenue'],
+      recipients: ['user-1'],
+      endDate: '2026-07-31',
+    })
+
+    expect(result.success).toBe(true)
+    const data = vi.mocked(db.reportSchedule.create).mock.calls[0][0].data
+    // 08:00 CDT on 8 Jul is 13:00Z, not the 08:00Z a UTC server would pick
+    expect(data.nextRunDate).toEqual(new Date('2026-07-08T13:00:00.000Z'))
+    expect(data.endDate).toEqual(new Date('2026-08-01T04:59:59.999Z'))
+  })
+})
+
+describe('processOneSchedule', () => {
+  it('reports over whole workshop days, buckets by workshop month and reschedules at 08:00 local', async () => {
+    vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
+    vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
+      {
+        serviceDate: new Date('2026-07-01T03:00:00Z'),
+        // 30 Jun 22:00 in Chicago, still June there
+        startDateTime: new Date('2026-07-01T03:00:00Z'),
+        totalAmount: 100,
+        cost: 100,
+        type: 'repair',
+        taxRate: 0,
+        taxInclusive: false,
+        manuallyPaid: true,
+        payments: [],
+        partItems: [],
+        laborItems: [],
+      },
+    ] as any)
+    vi.mocked(db.user.findMany).mockResolvedValue([] as any)
+    vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Shop' } as any)
+    vi.mocked(db.reportSchedule.update).mockResolvedValue({} as any)
+
+    await processOneSchedule(
+      {
+        id: 'sched-1',
+        name: 'Weekly',
+        frequency: 'daily',
+        dateRange: 'last7d',
+        sections: JSON.stringify(['revenue']),
+        recipients: JSON.stringify([]),
+        organizationId: 'org-1',
+        endDate: null,
+      },
+      'America/Chicago'
+    )
+
+    const where = vi.mocked(db.serviceRecord.findMany).mock.calls[0][0]?.where as any
+    expect(where.startDateTime).toEqual({
+      gte: new Date('2026-06-24T05:00:00.000Z'),
+      lte: new Date('2026-07-02T04:59:59.999Z'),
+    })
+
+    const element = vi.mocked(renderToBuffer).mock.calls[0][0] as any
+    expect(element.props.dateRange).toBe('Jun 24, 2026 – Jul 1, 2026')
+    expect(element.props.revenueData.monthly.map((m: any) => m.month)).toEqual(['2026-06'])
+
+    const update = vi.mocked(db.reportSchedule.update).mock.calls[0][0].data as any
+    expect(update.nextRunDate).toEqual(new Date('2026-07-02T13:00:00.000Z'))
+  })
+})

+ 2 - 0
src/__tests__/features/reports/reports-tax.test.ts

@@ -24,6 +24,8 @@ vi.mock('@/lib/cached-session', () => ({
   getCachedMembership: vi.fn(),
 }))
 
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: async () => 'UTC' }))
+
 vi.mock('@/lib/db', () => ({
   db: {
     user: { findUnique: vi.fn() },

+ 4 - 0
src/__tests__/features/workorders/create-workorder.test.ts

@@ -22,6 +22,10 @@ vi.mock('@/lib/cached-session', () => ({
 }))
 
 vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/workshop-timezone', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('@/lib/workshop-timezone')>()),
+  workshopTimeZone: async () => 'UTC',
+}))
 
 vi.mock('@/lib/invoice-utils', async (importOriginal) => ({
   ...(await importOriginal<typeof import('@/lib/invoice-utils')>()),

+ 2 - 2
src/__tests__/lib/auth-origin-hint.test.ts

@@ -118,7 +118,7 @@ describe('warnAboutAppUrl', () => {
   })
 
   it('warns when unset, invalid, or localhost in production', () => {
-    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+    const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
     vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
     warnAboutAppUrl()
     vi.stubEnv('NEXT_PUBLIC_APP_URL', 'torqvoice.example.com')
@@ -131,7 +131,7 @@ describe('warnAboutAppUrl', () => {
   })
 
   it('stays quiet for a proper public address', () => {
-    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+    const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
     vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://torqvoice.example.com')
     vi.stubEnv('NODE_ENV', 'production')
     warnAboutAppUrl()

+ 100 - 0
src/__tests__/lib/workshop-datetime.test.ts

@@ -0,0 +1,100 @@
+import { describe, expect, it } from 'vitest'
+import { parseWorkshopDateTime } from '@/lib/workshop-datetime'
+
+describe('parseWorkshopDateTime', () => {
+  it('reads a wall-clock time in the workshop zone, not the server zone', () => {
+    expect(parseWorkshopDateTime('2026-09-07T18:00', 'Europe/Oslo').toISOString()).toBe(
+      '2026-09-07T16:00:00.000Z'
+    )
+    expect(parseWorkshopDateTime('2026-01-07T18:00', 'Europe/Oslo').toISOString()).toBe(
+      '2026-01-07T17:00:00.000Z'
+    )
+    expect(parseWorkshopDateTime('2026-09-07T18:00:30', 'America/New_York').toISOString()).toBe(
+      '2026-09-07T22:00:00.000Z'
+    )
+  })
+
+  it('treats a bare date as that day at midnight in the workshop', () => {
+    expect(parseWorkshopDateTime('2026-09-07', 'Europe/Oslo').toISOString()).toBe(
+      '2026-09-06T22:00:00.000Z'
+    )
+  })
+
+  it('leaves a string with its own zone alone', () => {
+    expect(parseWorkshopDateTime('2026-09-07T18:00:00Z', 'Europe/Oslo').toISOString()).toBe(
+      '2026-09-07T18:00:00.000Z'
+    )
+  })
+
+  it('rejects rubbish', () => {
+    expect(() => parseWorkshopDateTime('soon', 'Europe/Oslo')).toThrow('Invalid send time')
+  })
+})
+
+import {
+  endOfWorkshopDay,
+  shiftWorkshopTime,
+  workshopDayRange,
+  workshopMonthKey,
+} from '@/lib/workshop-datetime'
+
+describe('endOfWorkshopDay', () => {
+  it('is the last millisecond of that day in the workshop', () => {
+    expect(endOfWorkshopDay('2026-09-07', 'Europe/Oslo')?.toISOString()).toBe(
+      '2026-09-07T21:59:59.999Z'
+    )
+    expect(endOfWorkshopDay('', 'Europe/Oslo')).toBeUndefined()
+  })
+})
+
+describe('workshopDayRange', () => {
+  it('covers whole workshop days, inclusive of the last', () => {
+    const r = workshopDayRange('2026-09-01', '2026-09-30', 'America/Chicago', {
+      start: new Date(0),
+      end: new Date(0),
+    })
+    expect(r.gte.toISOString()).toBe('2026-09-01T05:00:00.000Z')
+    expect(r.lt.toISOString()).toBe('2026-10-01T05:00:00.000Z')
+  })
+
+  it('falls back when a key is missing', () => {
+    const fallback = {
+      start: new Date('2026-01-01T00:00:00Z'),
+      end: new Date('2026-01-31T12:00:00Z'),
+    }
+    const r = workshopDayRange(undefined, 'nope', 'UTC', fallback)
+    expect(r.gte.toISOString()).toBe('2026-01-01T00:00:00.000Z')
+    expect(r.lt.toISOString()).toBe('2026-02-01T00:00:00.000Z')
+  })
+})
+
+describe('shiftWorkshopTime', () => {
+  it('keeps the wall-clock time across a DST change', () => {
+    // 18:00 Oslo on 20 Oct (CEST) plus 14 days is 18:00 Oslo on 3 Nov (CET)
+    const start = new Date('2026-10-20T16:00:00Z')
+    expect(shiftWorkshopTime(start, { days: 14 }, 'Europe/Oslo').toISOString()).toBe(
+      '2026-11-03T17:00:00.000Z'
+    )
+  })
+
+  it('clamps the day at the end of a shorter month', () => {
+    const jan31 = new Date('2026-01-31T09:00:00Z')
+    expect(shiftWorkshopTime(jan31, { months: 1 }, 'UTC').toISOString()).toBe(
+      '2026-02-28T09:00:00.000Z'
+    )
+  })
+
+  it('steps years', () => {
+    expect(
+      shiftWorkshopTime(new Date('2026-03-01T08:00:00Z'), { years: 1 }, 'UTC').toISOString()
+    ).toBe('2027-03-01T08:00:00.000Z')
+  })
+})
+
+describe('workshopMonthKey', () => {
+  it('buckets by the workshop month, not the UTC one', () => {
+    const lateEvening = new Date('2026-09-01T01:30:00Z') // 31 Aug 20:30 in Chicago
+    expect(workshopMonthKey(lateEvening, 'America/Chicago')).toBe('2026-08')
+    expect(workshopMonthKey(lateEvening, 'UTC')).toBe('2026-09')
+  })
+})

+ 54 - 24
src/app/(authenticated)/calendar/page.tsx

@@ -6,36 +6,66 @@ import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { PageHeader } from '@/components/page-header'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getAvailableChannels } from '@/features/scheduled-messages/Lib/availableChannels'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { zonedDayKey } from '@/lib/timezone'
 import CalendarClient from '@/features/calendar/Components/CalendarClient'
+import {
+  getMonthGridDays,
+  isCalendarView,
+  parseDateKey,
+  toLocalDateStr,
+  visibleRange,
+  type CalendarView,
+} from '@/features/calendar/Lib/calendar-range'
 
-function toLocalDateStr(d: Date): string {
-  const y = d.getFullYear()
-  const m = String(d.getMonth() + 1).padStart(2, '0')
-  const day = String(d.getDate()).padStart(2, '0')
-  return `${y}-${m}-${day}`
-}
+export default async function CalendarPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ view?: string; date?: string }>
+}) {
+  const params = await searchParams
+  const ctx = await getAuthContext()
+  const timeZone = ctx ? await workshopTimeZone(ctx.organizationId) : 'UTC'
+  // "Today" is the workshop's today, not the server's: a box in UTC must not
+  // ring in the next day at 01:00 Oslo time.
+  const todayStr = zonedDayKey(new Date(), timeZone)
 
-export default async function CalendarPage() {
-  const now = new Date()
-  const start = new Date(now.getFullYear(), now.getMonth(), 1)
-  const end = new Date(now.getFullYear(), now.getMonth() + 1, 0)
+  const view: CalendarView = isCalendarView(params.view) ? params.view : 'month'
+  const date = parseDateKey(params.date) ?? parseDateKey(todayStr) ?? new Date()
 
-  const ctx = await getAuthContext()
   const messageChannels = ctx ? await getAvailableChannels(ctx.organizationId) : []
+  const settingsResult = await getSettings([
+    SETTING_KEYS.CURRENCY_CODE,
+    SETTING_KEYS.WORKBOARD_WEEK_START_DAY,
+  ])
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  // The same week start the layout hands the client, so the grid the server
+  // fetched for is the grid the client draws.
+  const weekStartRaw = parseInt(settings[SETTING_KEYS.WORKBOARD_WEEK_START_DAY] || '1', 10)
+  const weekStartDay = weekStartRaw >= 0 && weekStartRaw <= 6 ? weekStartRaw : 1
+
+  // The month grid around the date, widened to what the view shows; the
+  // client asks for the same shape when it moves, so the first paint and
+  // the first navigation agree on what is already loaded.
+  const visible = visibleRange(view, date, weekStartDay)
+  const grid = getMonthGridDays(date.getFullYear(), date.getMonth(), weekStartDay)
+  const range =
+    view === 'year'
+      ? visible
+      : {
+          start: grid[0] < visible.start ? grid[0] : visible.start,
+          end: grid[grid.length - 1] > visible.end ? grid[grid.length - 1] : visible.end,
+        }
+  const rangeKeys = { start: toLocalDateStr(range.start), end: toLocalDateStr(range.end) }
 
-  const [eventsResult, settingsResult, vehiclesResult, customersResult] = await Promise.all([
-    getCalendarEvents({
-      start: toLocalDateStr(start),
-      end: toLocalDateStr(end),
-    }),
-    getSettings([SETTING_KEYS.CURRENCY_CODE]),
+  const [eventsResult, vehiclesResult, customersResult] = await Promise.all([
+    getCalendarEvents(rangeKeys),
     getVehicles(),
     getCustomersList(),
   ])
 
   const events = eventsResult.success && eventsResult.data ? eventsResult.data : []
-  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
   const vehicles =
     vehiclesResult.success && vehiclesResult.data
       ? vehiclesResult.data.map((v) => ({
@@ -52,13 +82,13 @@ export default async function CalendarPage() {
   return (
     <>
       <PageHeader />
-      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
+      <div className="flex min-h-0 flex-1 flex-col gap-4 p-4 pt-0">
         <CalendarClient
           initialEvents={events}
-          initialMonth={now.getMonth()}
-          initialYear={now.getFullYear()}
-          initialDay={now.getDate()}
-          todayStr={toLocalDateStr(now)}
+          initialRange={rangeKeys}
+          initialView={view}
+          initialDate={toLocalDateStr(date)}
+          todayStr={todayStr}
           vehicles={vehicles}
           customers={customers}
           currencyCode={currencyCode}

+ 10 - 6
src/app/(authenticated)/vehicles/[id]/service/new/page.tsx

@@ -3,6 +3,8 @@ import { createDraftServiceRecord } from '@/features/vehicles/Actions/createDraf
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 
 export default async function NewServicePage({
   params,
@@ -17,8 +19,10 @@ export default async function NewServicePage({
   // Guard: if a pending draft already exists for this vehicle (created within the last 5 seconds),
   // reuse it instead of creating a duplicate. This prevents double-creation from
   // Next.js Server Component re-renders.
+  let timeZone = 'UTC'
   const existingResult = await withAuth(
     async ({ organizationId }) => {
+      timeZone = await workshopTimeZone(organizationId)
       const fiveSecondsAgo = new Date(Date.now() - 5000)
       return db.serviceRecord.findFirst({
         where: {
@@ -48,16 +52,16 @@ export default async function NewServicePage({
   const boardStart = query.boardStart
   const boardEnd = query.boardEnd
 
-  // Build startDateTime/endDateTime from board context
+  // Build startDateTime/endDateTime from board context. The board sends the
+  // workshop's wall clock, so it is read in the workshop's zone, not the
+  // server's.
   let startDateTime: Date | undefined
   let endDateTime: Date | undefined
   if (boardDate) {
-    startDateTime = boardStart
-      ? new Date(`${boardDate}T${boardStart}:00`)
-      : new Date(`${boardDate}T08:00:00`)
+    startDateTime = toSafeWorkshopDate(`${boardDate}T${boardStart || '08:00'}`, timeZone)
     endDateTime = boardEnd
-      ? new Date(`${boardDate}T${boardEnd}:00`)
-      : new Date(startDateTime.getTime() + 3600000)
+      ? toSafeWorkshopDate(`${boardDate}T${boardEnd}`, timeZone)
+      : startDateTime && new Date(startDateTime.getTime() + 3600000)
   }
 
   const boardTechId = query.boardTech

+ 8 - 2
src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx

@@ -211,6 +211,7 @@ interface VehicleDetail {
     title: string
     description: string | null
     dueDate: Date | null
+    hasDueTime?: boolean
     dueMileage: number | null
     isCompleted: boolean
     createdAt: Date
@@ -336,7 +337,7 @@ export function VehicleDetailClient({
   const distUnit = isMarine ? 'hrs' : unitSystem === 'metric' ? 'km' : 'mi'
   const router = useRouter()
   const searchParams = useSearchParams()
-  const { formatDate } = useFormatDate()
+  const { formatDate, formatDateTime } = useFormatDate()
   const t = useTranslations('vehicles.detail')
   const ti = useTranslations('vehicles.inspections')
   const tr = useTranslations('vehicles.reminders')
@@ -1675,7 +1676,12 @@ export function VehicleDetailClient({
                             )}
                           </div>
                           <p className="text-xs text-muted-foreground">
-                            {r.dueDate && tr('due', { date: formatDate(new Date(r.dueDate)) })}
+                            {r.dueDate &&
+                              tr('due', {
+                                date: r.hasDueTime
+                                  ? formatDateTime(new Date(r.dueDate))
+                                  : formatDate(new Date(r.dueDate)),
+                              })}
                             {r.dueMileage &&
                               `${r.dueDate ? ' · ' : ''}${tr('dueAt', { mileage: r.dueMileage.toLocaleString(), unit: distUnit })}`}
                           </p>

+ 10 - 10
src/app/(authenticated)/work-board/page.tsx

@@ -8,16 +8,14 @@ import {
   getWorkBoardSettings,
 } from '@/features/workboard/Actions/boardActions'
 import { WorkBoardClient } from '@/features/workboard/Components/WorkBoardClient'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { addZonedDays, zonedDayKey, zonedParts } from '@/lib/timezone'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 
-function getWeekStart(date: Date, weekStartDay: number): string {
-  const d = new Date(date)
-  const currentDay = d.getDay()
-  const diff = (currentDay - weekStartDay + 7) % 7
-  d.setDate(d.getDate() - diff)
-  const y = d.getFullYear()
-  const m = String(d.getMonth() + 1).padStart(2, '0')
-  const dd = String(d.getDate()).padStart(2, '0')
-  return `${y}-${m}-${dd}`
+// The week that holds the workshop's today, on the workshop's clock.
+function getWeekStart(date: Date, weekStartDay: number, timeZone: string): string {
+  const diff = (zonedParts(date, timeZone).weekday - weekStartDay + 7) % 7
+  return zonedDayKey(addZonedDays(date, -diff, timeZone), timeZone)
 }
 
 export default async function WorkBoardPage() {
@@ -27,7 +25,9 @@ export default async function WorkBoardPage() {
       ? settingsResult.data
       : { weekStartDay: 1, workDayStart: '07:00', workDayEnd: '15:00' }
 
-  const weekStart = getWeekStart(new Date(), boardSettings.weekStartDay)
+  const ctx = await getAuthContext()
+  const timeZone = ctx ? await workshopTimeZone(ctx.organizationId) : 'UTC'
+  const weekStart = getWeekStart(new Date(), boardSettings.weekStartDay, timeZone)
 
   const [techResult, bayResult, assignResult, unassignedResult] = await Promise.all([
     getTechnicians(),

+ 11 - 2
src/app/(presenter)/work-board/presenter/page.tsx

@@ -4,6 +4,9 @@ import { getTechnicians } from '@/features/workboard/Actions/technicianActions'
 import { getBoardJobs, getWorkBoardSettings } from '@/features/workboard/Actions/boardActions'
 import { getWorkBays } from '@/features/workboard/Actions/workBayActions'
 import { WorkBoardPresenter } from '@/features/workboard/Components/WorkBoardPresenter'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { zonedDate, zonedParts } from '@/lib/timezone'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 
 export const dynamic = 'force-dynamic'
 
@@ -23,9 +26,15 @@ export default async function WorkBoardPresenterPage({
   searchParams: Promise<{ date?: string }>
 }) {
   const params = await searchParams
-  const baseDate = params.date ? new Date(params.date + 'T12:00:00') : new Date()
-
   const techPromise = getTechnicians()
+  // Without a date the board opens on the workshop's today, not the server's.
+  const ctx = await getAuthContext()
+  const timeZone = ctx ? await workshopTimeZone(ctx.organizationId) : 'UTC'
+  const today = zonedParts(new Date(), timeZone)
+  const baseDate = params.date
+    ? new Date(params.date + 'T12:00:00')
+    : zonedDate(today.year, today.month, today.day, 12, 0, timeZone)
+
   const settingsResult = await getWorkBoardSettings()
 
   const boardSettings =

+ 14 - 3
src/app/(public)/portal/[orgId]/dashboard/page.tsx

@@ -15,6 +15,8 @@ import {
 } from 'lucide-react'
 import Link from 'next/link'
 import { getTranslations } from 'next-intl/server'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 
 export default async function PortalDashboardPage({
   params,
@@ -22,6 +24,9 @@ export default async function PortalDashboardPage({
   params: Promise<{ orgId: string }>
 }) {
   const { orgId } = await params
+  // Dates on the portal are the workshop's calendar days, not the server's.
+  const org = await resolvePortalOrg(orgId)
+  const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.dashboard')
   const tInvoices = await getTranslations('portal.invoices')
   const result = await getPortalDashboard()
@@ -130,7 +135,10 @@ export default async function PortalDashboardPage({
                       </p>
                       <p className="truncate text-xs text-muted-foreground">
                         {inv.vehicle?.make} {inv.vehicle?.model} ·{' '}
-                        {new Date(inv.startDateTime ?? inv.serviceDate).toLocaleDateString()}
+                        {new Date(inv.startDateTime ?? inv.serviceDate).toLocaleDateString(
+                          undefined,
+                          { timeZone }
+                        )}
                       </p>
                     </div>
                     <div className="flex shrink-0 items-center gap-3">
@@ -224,7 +232,10 @@ export default async function PortalDashboardPage({
                     <p className="truncate text-sm font-medium">{job.title}</p>
                     <p className="truncate text-xs text-muted-foreground">
                       {job.vehicle?.make} {job.vehicle?.model} ·{' '}
-                      {new Date(job.startDateTime ?? job.serviceDate).toLocaleDateString()}
+                      {new Date(job.startDateTime ?? job.serviceDate).toLocaleDateString(
+                        undefined,
+                        { timeZone }
+                      )}
                     </p>
                   </div>
                   <Badge variant="secondary" className="text-xs">
@@ -260,7 +271,7 @@ export default async function PortalDashboardPage({
                     <p className="truncate text-sm font-medium">{req.description.slice(0, 80)}</p>
                     <p className="truncate text-xs text-muted-foreground">
                       {req.vehicle?.make} {req.vehicle?.model} ·{' '}
-                      {new Date(req.createdAt).toLocaleDateString()}
+                      {new Date(req.createdAt).toLocaleDateString(undefined, { timeZone })}
                     </p>
                   </div>
                   <Badge variant="secondary" className="text-xs">

+ 9 - 2
src/app/(public)/portal/[orgId]/inspections/page.tsx

@@ -12,6 +12,8 @@ import {
 import { ClipboardCheck } from 'lucide-react'
 import Link from 'next/link'
 import { getTranslations } from 'next-intl/server'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 
 export default async function PortalInspectionsPage({
   params,
@@ -19,6 +21,9 @@ export default async function PortalInspectionsPage({
   params: Promise<{ orgId: string }>
 }) {
   const { orgId } = await params
+  // Dates on the portal are the workshop's calendar days, not the server's.
+  const org = await resolvePortalOrg(orgId)
+  const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.inspections')
   const result = await getPortalInspections()
 
@@ -94,7 +99,7 @@ export default async function PortalInspectionsPage({
                         </span>
                       )}
                       <span className="text-muted-foreground">
-                        {new Date(insp.createdAt).toLocaleDateString()}
+                        {new Date(insp.createdAt).toLocaleDateString(undefined, { timeZone })}
                       </span>
                     </div>
                     {insp.publicToken && (
@@ -149,7 +154,9 @@ export default async function PortalInspectionsPage({
                             {insp.status.replace('_', ' ')}
                           </Badge>
                         </TableCell>
-                        <TableCell>{new Date(insp.createdAt).toLocaleDateString()}</TableCell>
+                        <TableCell>
+                          {new Date(insp.createdAt).toLocaleDateString(undefined, { timeZone })}
+                        </TableCell>
                         <TableCell>
                           <div className="flex gap-2 text-xs">
                             {conditions.good && (

+ 13 - 2
src/app/(public)/portal/[orgId]/invoices/page.tsx

@@ -12,6 +12,8 @@ import {
 import { Download, FileText } from 'lucide-react'
 import Link from 'next/link'
 import { getTranslations } from 'next-intl/server'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 
 export default async function PortalInvoicesPage({
   params,
@@ -19,6 +21,9 @@ export default async function PortalInvoicesPage({
   params: Promise<{ orgId: string }>
 }) {
   const { orgId } = await params
+  // Dates on the portal are the workshop's calendar days, not the server's.
+  const org = await resolvePortalOrg(orgId)
+  const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.invoices')
   const result = await getPortalInvoices()
 
@@ -85,7 +90,10 @@ export default async function PortalInvoicesPage({
                         </span>
                       )}
                       <span>
-                        {new Date(inv.startDateTime ?? inv.serviceDate).toLocaleDateString()}
+                        {new Date(inv.startDateTime ?? inv.serviceDate).toLocaleDateString(
+                          undefined,
+                          { timeZone }
+                        )}
                       </span>
                     </div>
                     <div className="mt-3 flex items-center gap-4">
@@ -136,7 +144,10 @@ export default async function PortalInvoicesPage({
                           {inv.vehicle?.make} {inv.vehicle?.model}
                         </TableCell>
                         <TableCell>
-                          {new Date(inv.startDateTime ?? inv.serviceDate).toLocaleDateString()}
+                          {new Date(inv.startDateTime ?? inv.serviceDate).toLocaleDateString(
+                            undefined,
+                            { timeZone }
+                          )}
                         </TableCell>
                         <TableCell>${inv.totalAmount.toFixed(2)}</TableCell>
                         <TableCell>

+ 10 - 2
src/app/(public)/portal/[orgId]/quotes/page.tsx

@@ -12,9 +12,14 @@ import {
 import { FileQuestion } from 'lucide-react'
 import Link from 'next/link'
 import { getTranslations } from 'next-intl/server'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 
 export default async function PortalQuotesPage({ params }: { params: Promise<{ orgId: string }> }) {
   const { orgId } = await params
+  // Dates on the portal are the workshop's calendar days, not the server's.
+  const org = await resolvePortalOrg(orgId)
+  const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.quotes')
   const result = await getPortalQuotes()
 
@@ -72,7 +77,8 @@ export default async function PortalQuotesPage({ params }: { params: Promise<{ o
                     )}
                     {q.validUntil && (
                       <span>
-                        {t('validUntil')}: {new Date(q.validUntil).toLocaleDateString()}
+                        {t('validUntil')}:{' '}
+                        {new Date(q.validUntil).toLocaleDateString(undefined, { timeZone })}
                       </span>
                     )}
                   </div>
@@ -125,7 +131,9 @@ export default async function PortalQuotesPage({ params }: { params: Promise<{ o
                       </TableCell>
                       <TableCell>${q.totalAmount.toFixed(2)}</TableCell>
                       <TableCell>
-                        {q.validUntil ? new Date(q.validUntil).toLocaleDateString() : '-'}
+                        {q.validUntil
+                          ? new Date(q.validUntil).toLocaleDateString(undefined, { timeZone })
+                          : '-'}
                       </TableCell>
                       <TableCell>
                         {q.publicToken && (

+ 7 - 2
src/app/(public)/portal/[orgId]/request-service/page.tsx

@@ -7,6 +7,8 @@ import {
 import { Badge } from '@/components/ui/badge'
 import { AppCard } from '@/components/app-card'
 import { getTranslations } from 'next-intl/server'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 
 export default async function PortalRequestServicePage({
   params,
@@ -14,6 +16,9 @@ export default async function PortalRequestServicePage({
   params: Promise<{ orgId: string }>
 }) {
   const { orgId } = await params
+  // Dates on the portal are the workshop's calendar days, not the server's.
+  const org = await resolvePortalOrg(orgId)
+  const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.requestService')
   const [vehiclesResult, requestsResult] = await Promise.all([
     getPortalVehicles(),
@@ -75,9 +80,9 @@ export default async function PortalRequestServicePage({
                     </p>
                     <p className="mt-1 text-sm text-muted-foreground">{req.description}</p>
                     <p className="mt-1 text-xs text-muted-foreground">
-                      {new Date(req.createdAt).toLocaleDateString()}
+                      {new Date(req.createdAt).toLocaleDateString(undefined, { timeZone })}
                       {req.preferredDate &&
-                        ` - ${t('preferred', { date: new Date(req.preferredDate).toLocaleDateString() })}`}
+                        ` - ${t('preferred', { date: new Date(req.preferredDate).toLocaleDateString(undefined, { timeZone }) })}`}
                     </p>
                     {req.adminNotes && (
                       <p className="mt-1 text-xs text-muted-foreground">

+ 11 - 3
src/app/(public)/portal/[orgId]/vehicles/[vehicleId]/page.tsx

@@ -6,6 +6,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
 import { ClipboardCheck, Download, Wrench } from 'lucide-react'
 import Link from 'next/link'
 import { getTranslations } from 'next-intl/server'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { db } from '@/lib/db'
 import { getWarrantyStatus, type WarrantyStatus } from '@/lib/warranty'
 import { ReportSoldButton } from '@/features/portal/Components/ReportSoldButton'
@@ -23,6 +25,9 @@ export default async function PortalVehicleDetailPage({
   params: Promise<{ orgId: string; vehicleId: string }>
 }) {
   const { orgId, vehicleId } = await params
+  // Dates on the portal are the workshop's calendar days, not the server's.
+  const org = await resolvePortalOrg(orgId)
+  const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.vehicles')
   const tInvoices = await getTranslations('portal.invoices')
   const tWarranty = await getTranslations('vehicles.services.warranty.status')
@@ -118,7 +123,10 @@ export default async function PortalVehicleDetailPage({
                           {sr.title}
                         </p>
                         <p className="text-xs text-muted-foreground">
-                          {new Date(sr.startDateTime ?? sr.serviceDate).toLocaleDateString()}
+                          {new Date(sr.startDateTime ?? sr.serviceDate).toLocaleDateString(
+                            undefined,
+                            { timeZone }
+                          )}
                         </p>
                       </div>
                       <div className="flex items-center gap-3">
@@ -194,9 +202,9 @@ export default async function PortalVehicleDetailPage({
                         <div className="min-w-0">
                           <p className="truncate text-sm font-medium">{insp.template.name}</p>
                           <p className="text-xs text-muted-foreground">
-                            {new Date(insp.createdAt).toLocaleDateString()}
+                            {new Date(insp.createdAt).toLocaleDateString(undefined, { timeZone })}
                             {insp.completedAt &&
-                              ` - ${t('completed', { date: new Date(insp.completedAt).toLocaleDateString() })}`}
+                              ` - ${t('completed', { date: new Date(insp.completedAt).toLocaleDateString(undefined, { timeZone }) })}`}
                           </p>
                           <div className="mt-1 flex gap-2">
                             {conditions.good && (

+ 6 - 3
src/app/api/protected/backup/import-invoice-ninja/route.ts

@@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
 import { isDemoMode } from '@/lib/demo'
-import { resolveInvoicePrefix, toSafeDate } from '@/lib/invoice-utils'
+import { resolveInvoicePrefix } from '@/lib/invoice-utils'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { mkdir, writeFile, rm } from 'fs/promises'
 import path from 'path'
 import os from 'os'
@@ -213,6 +215,7 @@ export async function POST(request: NextRequest) {
   }
 
   const { organizationId, userId } = ctx
+  const timeZone = await workshopTimeZone(organizationId)
   let tmpDir: string | null = null
 
   try {
@@ -403,7 +406,7 @@ export async function POST(request: NextRequest) {
               type: 'repair',
               status: 'completed',
               cost: amount,
-              serviceDate: toSafeDate(invoice.date) ?? new Date(),
+              serviceDate: toSafeWorkshopDate(invoice.date, timeZone) ?? new Date(),
               invoiceNumber,
               subtotal: partsTotal + laborTotal,
               discountType:
@@ -515,7 +518,7 @@ export async function POST(request: NextRequest) {
             await tx.payment.create({
               data: {
                 amount: parseNum(payable.amount),
-                date: toSafeDate(payment.date) ?? new Date(),
+                date: toSafeWorkshopDate(payment.date, timeZone) ?? new Date(),
                 method: getPaymentMethod(payment.type_id),
                 note: payment.private_notes || null,
                 externalId: payment.transaction_reference || null,

+ 5 - 2
src/app/api/protected/backup/import-lubelog/route.ts

@@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
 import { isDemoMode } from '@/lib/demo'
-import { resolveInvoicePrefix, toSafeDate } from '@/lib/invoice-utils'
+import { resolveInvoicePrefix } from '@/lib/invoice-utils'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { mkdir, writeFile, stat, readdir, rm } from 'fs/promises'
 import { readFileSync } from 'fs'
 import path from 'path'
@@ -208,6 +210,7 @@ export async function POST(request: NextRequest) {
   }
 
   const { organizationId, userId } = ctx
+  const timeZone = await workshopTimeZone(organizationId)
   let tmpDir: string | null = null
 
   try {
@@ -379,7 +382,7 @@ export async function POST(request: NextRequest) {
             status: 'completed',
             cost,
             mileage: sr.Mileage || null,
-            serviceDate: toSafeDate(sr.Date) ?? new Date(),
+            serviceDate: toSafeWorkshopDate(sr.Date, timeZone) ?? new Date(),
             invoiceNumber,
             subtotal: cost,
             totalAmount: cost,

+ 15 - 2
src/app/api/protected/backup/import/route.ts

@@ -5,6 +5,9 @@ import { isDemoMode } from '@/lib/demo'
 import { clearPlanFor, UPLOAD_CATEGORIES } from '@/lib/backup/manifest'
 import { columnsOf } from '@/lib/backup/rows'
 import { toSafeDate } from '@/lib/invoice-utils'
+import { atZonedTime } from '@/lib/timezone'
+import { resolveWorkshopTimeZone } from '@/lib/workshop-timezone'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { Prisma } from '@/generated/prisma/client'
 import JSZip from 'jszip'
 import { mkdir, rm, writeFile } from 'fs/promises'
@@ -72,6 +75,8 @@ async function importServiceRecordTree(
     vehicleId: string | null
     customerId: string | null
     workDayStartTime: string
+    /** The restored workshop's zone, which workDayStartTime is read in. */
+    timeZone: string
     /** Technicians restored by this import. See the time entries below. */
     technicianIds: ReadonlySet<string>
     /**
@@ -93,8 +98,7 @@ async function importServiceRecordTree(
   if (!startDT && sr.serviceDate) {
     const sd = toSafeDate(sr.serviceDate as string)
     if (sd) {
-      const [h, m] = opts.workDayStartTime.split(':').map(Number)
-      startDT = new Date(sd.getFullYear(), sd.getMonth(), sd.getDate(), h, m, 0, 0)
+      startDT = atZonedTime(sd, opts.workDayStartTime, opts.timeZone)
     }
   }
   if (sr.endDateTime) {
@@ -679,6 +683,13 @@ export async function POST(request: NextRequest) {
         (s) => s.key === 'workboard.workDayStart'
       )
       const workDayStartTime = (workDayStartSetting?.value as string) || '07:00'
+      const importedSetting = (key: string) =>
+        (data.settings as Record<string, unknown>[] | undefined)?.find((s) => s.key === key)
+          ?.value as string | undefined
+      const timeZone = resolveWorkshopTimeZone(
+        importedSetting(SETTING_KEYS.TIMEZONE),
+        importedSetting(SETTING_KEYS.TIMEZONE_DETECTED)
+      )
 
       // 6. Insert vehicles with nested data
       if (data.vehicles?.length) {
@@ -776,6 +787,7 @@ export async function POST(request: NextRequest) {
                 vehicleId: v.id as string,
                 customerId: null,
                 workDayStartTime,
+                timeZone,
                 technicianIds,
                 designIds,
                 designSnapshotIds,
@@ -860,6 +872,7 @@ export async function POST(request: NextRequest) {
             vehicleId: null,
             customerId: (sr.customerId as string) || null,
             workDayStartTime,
+            timeZone,
             technicianIds,
             designIds,
             designSnapshotIds,

+ 3 - 0
src/app/api/v1/tech/jobs/[id]/notes/route.ts

@@ -2,6 +2,7 @@ import { z } from 'zod'
 import { db } from '@/lib/db'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 
 /**
  * Appends to the job's internal notes.
@@ -45,9 +46,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
       // Stamped, because a shared field with several authors and no
       // attribution becomes unreadable within a week. The web editor stores
       // HTML, so this matches rather than injecting bare newlines into it.
+      // Dated on the workshop's calendar, not the server's.
       const stamp = new Date().toLocaleDateString('en-GB', {
         day: 'numeric',
         month: 'short',
+        timeZone: await workshopTimeZone(ctx.organizationId),
       })
       const line = `<p><strong>${technician?.name ?? 'Technician'}, ${stamp}:</strong> ${escapeHtml(note.trim())}</p>`
 

+ 17 - 28
src/features/billing/Actions/recurringInvoiceActions.ts

@@ -10,9 +10,12 @@ import {
   type CreateRecurringInvoiceInput,
   type UpdateRecurringInvoiceInput,
 } from '../Schema/recurringInvoiceSchema'
-import { resolveInvoicePrefix, toSafeDate } from '@/lib/invoice-utils'
+import { resolveInvoicePrefix } from '@/lib/invoice-utils'
 import { calculateTotals } from '@/lib/tax'
 import { lineTotal } from '@/features/inventory/Lib/partPricing'
+import { calculateNextRunDate } from '@/lib/cron/recurring-invoices'
+import { endOfWorkshopDay, toSafeWorkshopDate } from '@/lib/workshop-datetime'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 
 export async function getRecurringInvoices() {
   return withAuth(
@@ -63,13 +66,16 @@ export async function createRecurringInvoice(input: CreateRecurringInvoiceInput)
         taxInclusive = setting?.value === 'true'
       }
 
+      // The dates are workshop days: the first run is that day's midnight
+      // on its clock, and the end date covers the whole of its last day
+      const timeZone = await workshopTimeZone(organizationId)
       const invoice = await db.recurringInvoice.create({
         data: {
           title: parsed.title,
           description: parsed.description,
           frequency: parsed.frequency,
-          nextRunDate: toSafeDate(parsed.nextRunDate) ?? new Date(),
-          endDate: toSafeDate(parsed.endDate) ?? null,
+          nextRunDate: toSafeWorkshopDate(parsed.nextRunDate, timeZone) ?? new Date(),
+          endDate: endOfWorkshopDay(parsed.endDate, timeZone) ?? null,
           vehicleId: parsed.vehicleId,
           type: parsed.type,
           cost: parsed.cost,
@@ -129,6 +135,8 @@ export async function updateRecurringInvoice(input: UpdateRecurringInvoiceInput)
         if (!vehicle) throw new Error('Vehicle not found')
       }
 
+      const timeZone = await workshopTimeZone(organizationId)
+
       // Update main record
       const updated = await db.$transaction(async (tx) => {
         // Delete existing template items if new ones provided
@@ -146,9 +154,11 @@ export async function updateRecurringInvoice(input: UpdateRecurringInvoiceInput)
             ...(parsed.description !== undefined && { description: parsed.description }),
             ...(parsed.frequency !== undefined && { frequency: parsed.frequency }),
             ...(parsed.nextRunDate !== undefined && {
-              nextRunDate: toSafeDate(parsed.nextRunDate) ?? new Date(),
+              nextRunDate: toSafeWorkshopDate(parsed.nextRunDate, timeZone) ?? new Date(),
+            }),
+            ...(parsed.endDate !== undefined && {
+              endDate: endOfWorkshopDay(parsed.endDate, timeZone) ?? null,
             }),
-            ...(parsed.endDate !== undefined && { endDate: toSafeDate(parsed.endDate) ?? null }),
             ...(parsed.vehicleId !== undefined && { vehicleId: parsed.vehicleId }),
             ...(parsed.type !== undefined && { type: parsed.type }),
             ...(parsed.cost !== undefined && { cost: parsed.cost }),
@@ -248,28 +258,6 @@ export async function toggleRecurringInvoice(id: string) {
   )
 }
 
-function calculateNextRunDate(current: Date, frequency: string): Date {
-  const next = new Date(current)
-  switch (frequency) {
-    case 'weekly':
-      next.setDate(next.getDate() + 7)
-      break
-    case 'biweekly':
-      next.setDate(next.getDate() + 14)
-      break
-    case 'monthly':
-      next.setMonth(next.getMonth() + 1)
-      break
-    case 'quarterly':
-      next.setMonth(next.getMonth() + 3)
-      break
-    case 'yearly':
-      next.setFullYear(next.getFullYear() + 1)
-      break
-  }
-  return next
-}
-
 async function generateInvoiceNumber(organizationId: string): Promise<string> {
   const settings = await db.appSetting.findMany({
     where: {
@@ -306,6 +294,7 @@ export async function processRecurringInvoices() {
   return withAuth(
     async ({ organizationId }) => {
       const now = new Date()
+      const timeZone = await workshopTimeZone(organizationId)
 
       const dueInvoices = await db.recurringInvoice.findMany({
         where: {
@@ -376,7 +365,7 @@ export async function processRecurringInvoices() {
           })
 
           // Update recurring invoice
-          const nextRunDate = calculateNextRunDate(ri.nextRunDate, ri.frequency)
+          const nextRunDate = calculateNextRunDate(ri.nextRunDate, ri.frequency, timeZone)
           const shouldDeactivate = ri.endDate && nextRunDate > ri.endDate
 
           await tx.recurringInvoice.update({

+ 117 - 69
src/features/calendar/Actions/calendarActions.ts

@@ -4,13 +4,19 @@ import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { getManifest } from '@/integrations/registry'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { addZonedDays, zonedDate, zonedDayKey, zonedParts } from '@/lib/timezone'
+
+export type CalendarEventType = 'service' | 'reminder' | 'quote' | 'message' | 'external'
 
 export type CalendarEvent = {
   id: string
   title: string
-  date: string // YYYY-MM-DD local date
-  time: string | null // HH:MM or null
-  type: 'service' | 'reminder' | 'quote' | 'message' | 'external'
+  date: string // YYYY-MM-DD in the workshop's timezone
+  time: string | null // HH:MM in the workshop's timezone, or null for an all-day item
+  /** HH:MM when the record knows when it ends; the views pick a default otherwise. */
+  endTime?: string | null
+  type: CalendarEventType
   status: string
   /** Only on scheduled-message events: email | sms | telegram | in_app */
   channel?: string
@@ -19,43 +25,63 @@ export type CalendarEvent = {
   customerName: string | null
   invoiceNumber: string | null
   amount: number | null
-  /** Only on external events: when it ends, whether it fills the day, where it came from. */
-  endTime?: string | null
+  /** Only on external events: whether it fills the day, where it came from. */
   allDay?: boolean
   source?: string | null
   externalUrl?: string | null
 }
 
-/** Format a Date as YYYY-MM-DD in local time (avoids UTC shift from toISOString) */
-function toLocalDateStr(d: Date): string {
-  const y = d.getFullYear()
-  const m = String(d.getMonth() + 1).padStart(2, '0')
-  const day = String(d.getDate()).padStart(2, '0')
-  return `${y}-${m}-${day}`
+function parseDayKey(value: string): { year: number; month: number; day: number } | null {
+  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
+  if (!m) return null
+  return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) }
 }
 
-function toTimeStr(d: Date): string {
-  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
+function toTimeStr(date: Date, timeZone: string): string {
+  const p = zonedParts(date, timeZone)
+  return `${String(p.hour).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}`
 }
 
+/**
+ * Everything the calendar shows between two days, inclusive, read in the
+ * workshop's own timezone. The server may run in UTC; a job booked for 10:00
+ * in Oslo has to land on the 10:00 row here and not at 08:00.
+ */
 export async function getCalendarEvents(params: { start: string; end: string }) {
   return withAuth(
     async ({ organizationId }) => {
-      const start = new Date(params.start)
-      const end = new Date(params.end)
-      end.setHours(23, 59, 59, 999)
+      const timeZone = await workshopTimeZone(organizationId)
+      const startKey = parseDayKey(params.start)
+      const endKey = parseDayKey(params.end)
+      if (!startKey || !endKey) return []
+
+      const start = zonedDate(startKey.year, startKey.month, startKey.day, 0, 0, timeZone)
+      // First instant of the day after the range, so a job at 23:30 on the
+      // last day is still inside it.
+      const endExclusive = addZonedDays(
+        zonedDate(endKey.year, endKey.month, endKey.day, 0, 0, timeZone),
+        1,
+        timeZone
+      )
+      const inRange = { gte: start, lt: endExclusive }
 
       const [services, reminders, quotes, scheduledMessages, external] = await Promise.all([
         db.serviceRecord.findMany({
           where: {
             organizationId,
-            startDateTime: { gte: start, lte: end },
+            OR: [
+              { startDateTime: inRange },
+              // A job entered with only a service date, never put on the
+              // board, still belongs on its day.
+              { startDateTime: null, serviceDate: inRange },
+            ],
           },
           select: {
             id: true,
             title: true,
             serviceDate: true,
             startDateTime: true,
+            endDateTime: true,
             status: true,
             invoiceNumber: true,
             totalAmount: true,
@@ -76,12 +102,13 @@ export async function getCalendarEvents(params: { start: string; end: string })
         db.reminder.findMany({
           where: {
             organizationId,
-            dueDate: { gte: start, lte: end },
+            dueDate: inRange,
           },
           select: {
             id: true,
             title: true,
             dueDate: true,
+            hasDueTime: true,
             isCompleted: true,
             vehicleId: true,
             customer: { select: { name: true } },
@@ -99,7 +126,7 @@ export async function getCalendarEvents(params: { start: string; end: string })
         db.quote.findMany({
           where: {
             organizationId,
-            validUntil: { gte: start, lte: end },
+            validUntil: inRange,
             status: { notIn: ['converted', 'rejected'] },
           },
           select: {
@@ -126,7 +153,7 @@ export async function getCalendarEvents(params: { start: string; end: string })
           where: {
             organizationId,
             status: { not: 'cancelled' },
-            sendAt: { gte: start, lte: end },
+            sendAt: inRange,
           },
           select: {
             id: true,
@@ -144,7 +171,7 @@ export async function getCalendarEvents(params: { start: string; end: string })
         }),
         // Busy time pulled from connected calendars; read-only on this side.
         db.externalCalendarEvent.findMany({
-          where: { organizationId, startAt: { lte: end }, endAt: { gte: start } },
+          where: { organizationId, startAt: { lt: endExclusive }, endAt: { gt: start } },
           select: {
             id: true,
             title: true,
@@ -158,65 +185,86 @@ export async function getCalendarEvents(params: { start: string; end: string })
         }),
       ])
 
+      const now = new Date()
+      const vehicleLabel = (v: { year: number; make: string; model: string } | null) =>
+        v ? `${v.year} ${v.make} ${v.model}` : ''
+
       const externalEvents: CalendarEvent[] = []
       for (const e of external) {
         // One entry per day the event covers, capped so a year-long block
-        // does not flood the month.
-        const first = new Date(e.startAt)
-        const last = new Date(e.endAt.getTime() - 1)
+        // does not flood the month. The last instant belongs to the day it
+        // falls on, so an event ending at midnight stops the day before.
+        const firstKey = zonedDayKey(e.startAt, timeZone)
+        const lastKey = zonedDayKey(new Date(e.endAt.getTime() - 1), timeZone)
         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++) {
-          if (d < start || d > end) continue
-          externalEvents.push({
-            id: `${e.id}:${n}`,
-            title: e.title,
-            date: toLocalDateStr(d),
-            time: e.allDay || n > 0 ? null : toTimeStr(e.startAt),
-            type: 'external' as const,
-            status: 'busy',
-            vehicleId: null,
-            vehicleLabel: '',
-            customerName: null,
-            invoiceNumber: null,
-            amount: null,
-            endTime: e.allDay ? null : toTimeStr(e.endAt),
-            allDay: e.allDay,
-            source,
-            externalUrl: e.remoteUrl,
-          })
+        let day = e.startAt
+        for (let n = 0; n < 31; n++) {
+          const key = zonedDayKey(day, timeZone)
+          if (key > lastKey) break
+          const isFirst = key === firstKey
+          const isLast = key === lastKey
+          if (key >= params.start && key <= params.end) {
+            externalEvents.push({
+              id: `${e.id}:${n}`,
+              title: e.title,
+              date: key,
+              time: e.allDay ? null : isFirst ? toTimeStr(e.startAt, timeZone) : '00:00',
+              endTime: e.allDay ? null : isLast ? toTimeStr(e.endAt, timeZone) : '23:59',
+              type: 'external',
+              status: 'busy',
+              vehicleId: null,
+              vehicleLabel: '',
+              customerName: null,
+              invoiceNumber: null,
+              amount: null,
+              allDay: e.allDay,
+              source,
+              externalUrl: e.remoteUrl,
+            })
+          }
+          day = addZonedDays(day, 1, timeZone)
         }
       }
 
       const events: CalendarEvent[] = [
         ...externalEvents,
-        ...services.map((s) => ({
-          id: s.id,
-          title: s.title,
-          date: toLocalDateStr(s.startDateTime ?? s.serviceDate),
-          time: s.startDateTime ? toTimeStr(s.startDateTime) : null,
-          type: 'service' as const,
-          status: s.status,
-          vehicleId: s.vehicleId,
-          vehicleLabel: s.vehicle ? `${s.vehicle.year} ${s.vehicle.make} ${s.vehicle.model}` : '',
-          customerName: (s.customer ?? s.vehicle?.customer)?.name ?? null,
-          invoiceNumber: s.invoiceNumber,
-          amount: s.totalAmount > 0 ? s.totalAmount : s.cost > 0 ? s.cost : null,
-        })),
+        ...services.map((s) => {
+          const startsAt = s.startDateTime
+          const date = zonedDayKey(startsAt ?? s.serviceDate, timeZone)
+          // An end on a later day is drawn to the end of this one; the
+          // calendar shows single-day blocks.
+          const endTime =
+            startsAt && s.endDateTime && s.endDateTime > startsAt
+              ? zonedDayKey(s.endDateTime, timeZone) === date
+                ? toTimeStr(s.endDateTime, timeZone)
+                : '23:59'
+              : null
+          return {
+            id: s.id,
+            title: s.title,
+            date,
+            time: startsAt ? toTimeStr(startsAt, timeZone) : null,
+            endTime,
+            type: 'service' as const,
+            status: s.status,
+            vehicleId: s.vehicleId,
+            vehicleLabel: vehicleLabel(s.vehicle),
+            customerName: (s.customer ?? s.vehicle?.customer)?.name ?? null,
+            invoiceNumber: s.invoiceNumber,
+            amount: s.totalAmount > 0 ? s.totalAmount : s.cost > 0 ? s.cost : null,
+          }
+        }),
         ...reminders
           .filter((r) => r.dueDate !== null)
           .map((r) => ({
             id: r.id,
             title: r.title,
-            date: toLocalDateStr(r.dueDate!),
-            time: null,
+            date: zonedDayKey(r.dueDate!, timeZone),
+            time: r.hasDueTime ? toTimeStr(r.dueDate!, timeZone) : null,
             type: 'reminder' as const,
-            status: r.isCompleted
-              ? 'completed'
-              : new Date(r.dueDate!) < new Date()
-                ? 'overdue'
-                : 'upcoming',
+            status: r.isCompleted ? 'completed' : r.dueDate! < now ? 'overdue' : 'upcoming',
             vehicleId: r.vehicleId,
-            vehicleLabel: r.vehicle ? `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}` : '',
+            vehicleLabel: vehicleLabel(r.vehicle),
             customerName: (r.customer ?? r.vehicle?.customer)?.name ?? null,
             invoiceNumber: null,
             amount: null,
@@ -226,12 +274,12 @@ export async function getCalendarEvents(params: { start: string; end: string })
           .map((q) => ({
             id: q.id,
             title: q.title,
-            date: toLocalDateStr(q.validUntil!),
+            date: zonedDayKey(q.validUntil!, timeZone),
             time: null,
             type: 'quote' as const,
             status: q.status,
             vehicleId: q.vehicleId!,
-            vehicleLabel: q.vehicle ? `${q.vehicle.year} ${q.vehicle.make} ${q.vehicle.model}` : '',
+            vehicleLabel: vehicleLabel(q.vehicle),
             customerName: q.customer?.name ?? q.vehicle?.customer?.name ?? null,
             invoiceNumber: q.quoteNumber,
             amount: q.totalAmount > 0 ? q.totalAmount : null,
@@ -240,14 +288,14 @@ export async function getCalendarEvents(params: { start: string; end: string })
           id: m.id,
           // A subject reads better in a day cell than the first line of the body
           title: m.subject?.trim() || m.body.slice(0, 60),
-          date: toLocalDateStr(m.sendAt),
-          time: toTimeStr(m.sendAt),
+          date: zonedDayKey(m.sendAt, timeZone),
+          time: toTimeStr(m.sendAt, timeZone),
           type: 'message' as const,
           // "scheduled" | "sent" | "failed", carrying the channel for the icon
           status: m.status,
           channel: m.channel,
           vehicleId: m.vehicleId,
-          vehicleLabel: m.vehicle ? `${m.vehicle.year} ${m.vehicle.make} ${m.vehicle.model}` : '',
+          vehicleLabel: vehicleLabel(m.vehicle),
           customerName: m.customer?.name ?? m.recipient ?? null,
           invoiceNumber: null,
           amount: null,

+ 452 - 336
src/features/calendar/Components/CalendarClient.tsx

@@ -1,24 +1,46 @@
 'use client'
 
-import { useState, useCallback, useMemo } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { usePathname } from 'next/navigation'
 import { useTranslations } from 'next-intl'
+import { useDayFormatter } from './useDayFormatter'
+import { CalendarDays } from 'lucide-react'
 import { useDateSettings } from '@/components/date-settings-context'
 import { Button } from '@/components/ui/button'
-import { Card, CardContent } from '@/components/ui/card'
-import { Checkbox } from '@/components/ui/checkbox'
-import { ChevronLeft, ChevronRight, Loader2, Plus, MousePointerClick } from 'lucide-react'
-import { CalendarDayCell } from './CalendarDayCell'
-import { CalendarEventList } from './CalendarEventList'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
+import { VehiclePickerDialog } from '@/components/vehicle-picker-dialog'
+import { useIsMobile } from '@/hooks/use-mobile'
 import { ReminderFormDialog } from '@/features/vehicles/Components/ReminderFormDialog'
 import { NewQuoteDialog } from '@/features/quotes/Components/NewQuoteDialog'
 import { ScheduleMessageDialog } from '@/features/scheduled-messages/Components/ScheduleMessageDialog'
 import type { MessageChannel } from '@/features/scheduled-messages/Schema/scheduledMessageSchema'
-import { toLocalDateStr } from './calendar-utils'
 import { getCalendarEvents } from '../Actions/calendarActions'
-import type { CalendarEvent } from '../Actions/calendarActions'
-import { VehiclePickerDialog } from '@/components/vehicle-picker-dialog'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { CalendarDays } from 'lucide-react'
+import type { CalendarEvent, CalendarEventType } from '../Actions/calendarActions'
+import {
+  eachDay,
+  getMonthGridDays,
+  minutesToTime,
+  parseDateKey,
+  rangeCovers,
+  shiftDate,
+  timeToMinutes,
+  toLocalDateStr,
+  visibleRange,
+  type CalendarView,
+  type DateRange,
+} from '../Lib/calendar-range'
+import { compareEvents, isEventDone } from './calendar-utils'
+import { CalendarEventList } from './CalendarEventList'
+import { CalendarSidebar, type TypeFilters } from './CalendarSidebar'
+import { CalendarToolbar, VIEW_SHORTCUTS, type CreateKind } from './CalendarToolbar'
+import type { DayActions } from './DayContextMenu'
+import { EventPeekProvider } from './EventPeek'
+import { MonthView } from './MonthView'
+import { ScheduleView } from './ScheduleView'
+import { TimeGridView } from './TimeGridView'
+import { useCalendarPreferences } from './useCalendarPreferences'
+import { YearView } from './YearView'
 
 interface Vehicle {
   id: string
@@ -37,9 +59,11 @@ interface Customer {
 
 interface CalendarClientProps {
   initialEvents: CalendarEvent[]
-  initialMonth: number
-  initialYear: number
-  initialDay: number
+  /** The days `initialEvents` covers, as YYYY-MM-DD. */
+  initialRange: { start: string; end: string }
+  initialView: CalendarView
+  /** YYYY-MM-DD the calendar opens on. */
+  initialDate: string
   todayStr: string // YYYY-MM-DD computed on server to avoid hydration mismatch
   vehicles: Vehicle[]
   customers: Customer[]
@@ -48,39 +72,31 @@ interface CalendarClientProps {
   messageChannels: MessageChannel[]
 }
 
-function getMonthDays(year: number, month: number, weekStartDay: number) {
-  const firstDay = new Date(year, month, 1)
-  const lastDay = new Date(year, month + 1, 0)
-  const startPad = (firstDay.getDay() - weekStartDay + 7) % 7
-
-  const days: Date[] = []
-
-  // Previous month padding
-  for (let i = startPad - 1; i >= 0; i--) {
-    days.push(new Date(year, month, -i))
+const ALL_TYPES: CalendarEventType[] = ['service', 'reminder', 'quote', 'message', 'external']
+
+/** Minutes a work order started from a time slot is booked for. */
+const SLOT_WORK_ORDER_MINUTES = 60
+
+/**
+ * What to fetch so the view is covered and the neighbouring views usually
+ * are too: the whole month grid around the date, widened to whatever the
+ * view itself shows, or the whole year for the year view.
+ */
+function fetchRangeFor(view: CalendarView, date: Date, weekStartDay: number): DateRange {
+  const visible = visibleRange(view, date, weekStartDay)
+  if (view === 'year') return visible
+  const grid = getMonthGridDays(date.getFullYear(), date.getMonth(), weekStartDay)
+  return {
+    start: grid[0] < visible.start ? grid[0] : visible.start,
+    end: grid[grid.length - 1] > visible.end ? grid[grid.length - 1] : visible.end,
   }
-
-  // Current month days
-  for (let d = 1; d <= lastDay.getDate(); d++) {
-    days.push(new Date(year, month, d))
-  }
-
-  // Next month padding (fill to complete the last week)
-  const remaining = 7 - (days.length % 7)
-  if (remaining < 7) {
-    for (let i = 1; i <= remaining; i++) {
-      days.push(new Date(year, month + 1, i))
-    }
-  }
-
-  return days
 }
 
 export default function CalendarClient({
   initialEvents,
-  initialMonth,
-  initialYear,
-  initialDay,
+  initialRange,
+  initialView,
+  initialDate,
   todayStr,
   vehicles,
   customers,
@@ -88,118 +104,291 @@ export default function CalendarClient({
   messageChannels,
 }: CalendarClientProps) {
   const t = useTranslations('calendar')
+  const format = useDayFormatter()
+  const pathname = usePathname()
+  const isMobile = useIsMobile()
   const { weekStartDay } = useDateSettings()
-  const WEEKDAY_LABELS = [
-    t('weekdays.sun'),
-    t('weekdays.mon'),
-    t('weekdays.tue'),
-    t('weekdays.wed'),
-    t('weekdays.thu'),
-    t('weekdays.fri'),
-    t('weekdays.sat'),
-  ]
-  const WEEKDAYS = Array.from({ length: 7 }, (_, i) => WEEKDAY_LABELS[(weekStartDay + i) % 7])
-  const MONTH_NAMES = [
-    t('months.january'),
-    t('months.february'),
-    t('months.march'),
-    t('months.april'),
-    t('months.may'),
-    t('months.june'),
-    t('months.july'),
-    t('months.august'),
-    t('months.september'),
-    t('months.october'),
-    t('months.november'),
-    t('months.december'),
-  ]
-
-  const [month, setMonth] = useState(initialMonth)
-  const [year, setYear] = useState(initialYear)
+  const { preferences, update: updatePreferences } = useCalendarPreferences()
+
+  const [view, setView] = useState<CalendarView>(initialView)
+  const [date, setDate] = useState<Date>(
+    () => parseDateKey(initialDate) ?? parseDateKey(todayStr) ?? new Date()
+  )
   const [events, setEvents] = useState<CalendarEvent[]>(initialEvents)
+  const [loadedRange, setLoadedRange] = useState<DateRange | null>(() => {
+    const start = parseDateKey(initialRange.start)
+    const end = parseDateKey(initialRange.end)
+    return start && end ? { start, end } : null
+  })
   const [loading, setLoading] = useState(false)
-  const [selectedDate, setSelectedDate] = useState<Date>(
-    () => new Date(initialYear, initialMonth, initialDay)
-  )
-
-  // Filters
-  const [showServices, setShowServices] = useState(true)
-  const [showReminders, setShowReminders] = useState(true)
-  const [showQuotes, setShowQuotes] = useState(true)
-  const [showMessages, setShowMessages] = useState(true)
-  const [showExternal, setShowExternal] = useState(true)
-
-  // Vehicle picker
+  const [filters, setFilters] = useState<TypeFilters>({
+    service: true,
+    reminder: true,
+    quote: true,
+    message: true,
+    external: true,
+  })
+
+  // Dialogs the calendar can open
   const [showPicker, setShowPicker] = useState(false)
   const [showDateChoice, setShowDateChoice] = useState(false)
-  const [workOrderDate, setWorkOrderDate] = useState<string | undefined>(undefined)
-
-  // Right-click menu targets: the day the menu was opened on
+  const [workOrderQuery, setWorkOrderQuery] = useState<Record<string, string> | undefined>()
   const [showReminderDialog, setShowReminderDialog] = useState(false)
   const [showQuoteDialog, setShowQuoteDialog] = useState(false)
   const [showMessageDialog, setShowMessageDialog] = useState(false)
   const [menuDateStr, setMenuDateStr] = useState<string | undefined>(undefined)
+  const [menuTime, setMenuTime] = useState<string | undefined>(undefined)
+  const [daySheetOpen, setDaySheetOpen] = useState(false)
 
-  const selectedDateStr = toLocalDateStr(selectedDate)
+  const dateStr = toLocalDateStr(date)
+  const range = useMemo(() => visibleRange(view, date, weekStartDay), [view, date, weekStartDay])
+  const days = useMemo(() => eachDay(range), [range])
 
-  const handleNewWorkOrder = useCallback(() => {
-    if (selectedDateStr !== todayStr) {
-      setShowDateChoice(true)
-    } else {
-      setWorkOrderDate(undefined)
-      setShowPicker(true)
+  // ── Loading ────────────────────────────────────────────────────────────
+  const requestId = useRef(0)
+  const load = useCallback(async (target: DateRange) => {
+    const id = ++requestId.current
+    setLoading(true)
+    const result = await getCalendarEvents({
+      start: toLocalDateStr(target.start),
+      end: toLocalDateStr(target.end),
+    })
+    // A slower earlier request must not overwrite a newer one.
+    if (id !== requestId.current) return
+    if (result.success && result.data) {
+      setEvents(result.data)
+      setLoadedRange(target)
     }
-  }, [selectedDateStr, todayStr])
+    setLoading(false)
+  }, [])
+
+  useEffect(() => {
+    if (rangeCovers(loadedRange, range)) return
+    load(fetchRangeFor(view, date, weekStartDay))
+  }, [range, loadedRange, load, view, date, weekStartDay])
+
+  const refresh = useCallback(() => {
+    load(loadedRange ?? fetchRangeFor(view, date, weekStartDay))
+  }, [load, loadedRange, view, date, weekStartDay])
+
+  // ── URL ────────────────────────────────────────────────────────────────
+  const firstRender = useRef(true)
+  useEffect(() => {
+    if (firstRender.current) {
+      firstRender.current = false
+      return
+    }
+    const params = new URLSearchParams()
+    params.set('view', view)
+    params.set('date', dateStr)
+    // Native replaceState rather than router.replace: the Next router folds
+    // it into its own history, and no server render is asked for. Flicking
+    // through months would otherwise re-run the whole page on the server.
+    window.history.replaceState(window.history.state, '', `${pathname}?${params.toString()}`)
+  }, [view, dateStr, pathname])
+
+  // ── Derived data ───────────────────────────────────────────────────────
+  const counts = useMemo(() => {
+    const c: Record<CalendarEventType, number> = {
+      service: 0,
+      reminder: 0,
+      quote: 0,
+      message: 0,
+      external: 0,
+    }
+    const startKey = toLocalDateStr(range.start)
+    const endKey = toLocalDateStr(range.end)
+    for (const e of events) {
+      if (e.date >= startKey && e.date <= endKey) c[e.type]++
+    }
+    return c
+  }, [events, range])
+  const total = ALL_TYPES.reduce((sum, type) => sum + counts[type], 0)
+
+  const eventsByDate = useMemo(() => {
+    const map = new Map<string, CalendarEvent[]>()
+    for (const e of events) {
+      if (!filters[e.type]) continue
+      if (
+        !preferences.showCompleted &&
+        (e.type === 'service' || e.type === 'reminder') &&
+        isEventDone(e)
+      ) {
+        continue
+      }
+      const list = map.get(e.date)
+      if (list) list.push(e)
+      else map.set(e.date, [e])
+    }
+    for (const list of map.values()) list.sort(compareEvents)
+    return map
+  }, [events, filters, preferences.showCompleted])
+
+  const busyDates = useMemo(
+    () =>
+      Array.from(eventsByDate.keys())
+        .map(parseDateKey)
+        .filter((d): d is Date => d !== null),
+    [eventsByDate]
+  )
+
+  const title = useMemo(() => {
+    switch (view) {
+      case 'day':
+        return format.dateTime(date, {
+          weekday: 'long',
+          day: 'numeric',
+          month: 'long',
+          year: 'numeric',
+        })
+      case 'week':
+      case 'fourDays': {
+        // Composed by hand rather than with a range formatter: ICU versions
+        // disagree on the spaces around the dash, and the server's text
+        // would not match the browser's.
+        const sameYear = range.start.getFullYear() === range.end.getFullYear()
+        const day = { day: 'numeric', month: 'short' } as const
+        const start = format.dateTime(range.start, sameYear ? day : { ...day, year: 'numeric' })
+        const end = format.dateTime(range.end, { ...day, year: 'numeric' })
+        return `${start} – ${end}`
+      }
+      case 'year':
+        return String(date.getFullYear())
+      default:
+        return format.dateTime(date, { month: 'long', year: 'numeric' })
+    }
+  }, [view, date, range, format])
+
+  // ── Navigation ─────────────────────────────────────────────────────────
+  const goPrev = useCallback(() => setDate((d) => shiftDate(view, d, -1)), [view])
+  const goNext = useCallback(() => setDate((d) => shiftDate(view, d, 1)), [view])
+  const goToday = useCallback(() => setDate(parseDateKey(todayStr) ?? new Date()), [todayStr])
+
+  const selectDate = useCallback(
+    (d: Date) => {
+      setDate(d)
+      if (isMobile && (view === 'month' || view === 'year')) setDaySheetOpen(true)
+    },
+    [isMobile, view]
+  )
+  const openDay = useCallback((d: Date) => {
+    setDate(d)
+    setView('day')
+  }, [])
+
+  useEffect(() => {
+    const onKey = (e: KeyboardEvent) => {
+      if (e.metaKey || e.ctrlKey || e.altKey) return
+      const target = e.target as HTMLElement | null
+      if (
+        target?.closest(
+          'input, textarea, select, [contenteditable="true"], [role="dialog"], [role="menu"]'
+        )
+      ) {
+        return
+      }
+      const key = e.key.length === 1 ? e.key.toUpperCase() : e.key
+      const viewFor = (Object.keys(VIEW_SHORTCUTS) as CalendarView[]).find(
+        (v) => VIEW_SHORTCUTS[v] === key
+      )
+      if (viewFor) {
+        setView(viewFor)
+        return
+      }
+      switch (key) {
+        case 'T':
+          goToday()
+          break
+        case 'J':
+        case 'N':
+        case 'ArrowRight':
+          goNext()
+          break
+        case 'K':
+        case 'P':
+        case 'ArrowLeft':
+          goPrev()
+          break
+        default:
+          return
+      }
+      e.preventDefault()
+    }
+    document.addEventListener('keydown', onKey)
+    return () => document.removeEventListener('keydown', onKey)
+  }, [goToday, goNext, goPrev])
+
+  // ── Creating things ────────────────────────────────────────────────────
+  const openWorkOrderPicker = useCallback((query?: Record<string, string>) => {
+    setWorkOrderQuery(query)
+    setShowPicker(true)
+  }, [])
+
+  const handleCreate = useCallback(
+    (kind: CreateKind) => {
+      setMenuDateStr(dateStr)
+      setMenuTime(undefined)
+      switch (kind) {
+        case 'workOrder':
+          // A day other than today is probably what the person means, but
+          // it might not be: ask, as the old button did.
+          if (dateStr !== todayStr) setShowDateChoice(true)
+          else openWorkOrderPicker(undefined)
+          break
+        case 'reminder':
+          setShowReminderDialog(true)
+          break
+        case 'quote':
+          setShowQuoteDialog(true)
+          break
+        case 'message':
+          setShowMessageDialog(true)
+          break
+      }
+    },
+    [dateStr, todayStr, openWorkOrderPicker]
+  )
 
   const handleDateChoice = useCallback(
     (useSelectedDate: boolean) => {
       setShowDateChoice(false)
-      setWorkOrderDate(useSelectedDate ? selectedDateStr : undefined)
-      setShowPicker(true)
+      openWorkOrderPicker(useSelectedDate ? { boardDate: dateStr } : undefined)
     },
-    [selectedDateStr]
+    [dateStr, openWorkOrderPicker]
   )
 
-  const filteredEvents = useMemo(() => {
-    return events.filter((e) => {
-      if (e.type === 'service' && !showServices) return false
-      if (e.type === 'reminder' && !showReminders) return false
-      if (e.type === 'quote' && !showQuotes) return false
-      if (e.type === 'message' && !showMessages) return false
-      if (e.type === 'external' && !showExternal) return false
-      return true
-    })
-  }, [events, showServices, showReminders, showQuotes, showMessages, showExternal])
-
-  // What the displayed month holds, counted before the filters hide anything
-  const counts = useMemo(
+  const dayActions = useMemo<DayActions>(
     () => ({
-      services: events.filter((e) => e.type === 'service').length,
-      reminders: events.filter((e) => e.type === 'reminder').length,
-      quotes: events.filter((e) => e.type === 'quote').length,
-      messages: events.filter((e) => e.type === 'message').length,
-      external: events.filter((e) => e.type === 'external').length,
-      total: events.length,
+      onNewWorkOrder: (day, time) => {
+        if (!time) {
+          openWorkOrderPicker({ boardDate: day })
+          return
+        }
+        const startMins = timeToMinutes(time)
+        openWorkOrderPicker({
+          boardDate: day,
+          boardStart: time,
+          boardEnd: minutesToTime(Math.min(startMins + SLOT_WORK_ORDER_MINUTES, 24 * 60 - 1)),
+        })
+      },
+      onNewReminder: (day, time) => {
+        setMenuDateStr(day)
+        setMenuTime(time)
+        setShowReminderDialog(true)
+      },
+      onNewQuote: (day) => {
+        setMenuDateStr(day)
+        setShowQuoteDialog(true)
+      },
+      onScheduleMessage: (day, time) => {
+        setMenuDateStr(day)
+        setMenuTime(time)
+        setShowMessageDialog(true)
+      },
     }),
-    [events]
+    [openWorkOrderPicker]
   )
 
-  const days = getMonthDays(year, month, weekStartDay)
-
-  const fetchEvents = useCallback(async (y: number, m: number) => {
-    setLoading(true)
-    const start = new Date(y, m, 1)
-    const end = new Date(y, m + 1, 0)
-    const result = await getCalendarEvents({
-      start: toLocalDateStr(start),
-      end: toLocalDateStr(end),
-    })
-    if (result.success && result.data) {
-      setEvents(result.data)
-    }
-    setLoading(false)
-  }, [])
-
   // Noon parse, so the seeded day survives any timezone the workshop sits in.
   // Memoised because the reminder dialog re-seeds whenever this value changes.
   const menuDate = useMemo(
@@ -222,208 +411,136 @@ export default function CalendarClient({
     [vehicles]
   )
 
-  // Right-click actions carry their day explicitly, so no date-choice prompt
-  const handleMenuWorkOrder = useCallback((dateStr: string) => {
-    setWorkOrderDate(dateStr)
-    setShowPicker(true)
-  }, [])
-
-  const handleMenuReminder = useCallback((dateStr: string) => {
-    setMenuDateStr(dateStr)
-    setShowReminderDialog(true)
-  }, [])
-
-  const handleMenuQuote = useCallback((dateStr: string) => {
-    setMenuDateStr(dateStr)
-    setShowQuoteDialog(true)
-  }, [])
-
-  const handleMenuMessage = useCallback((dateStr: string) => {
-    setMenuDateStr(dateStr)
-    setShowMessageDialog(true)
-  }, [])
-
-  const refreshMonth = useCallback(() => {
-    fetchEvents(year, month)
-  }, [fetchEvents, year, month])
-
-  const goToPrev = () => {
-    const newMonth = month === 0 ? 11 : month - 1
-    const newYear = month === 0 ? year - 1 : year
-    setMonth(newMonth)
-    setYear(newYear)
-    setSelectedDate(new Date(newYear, newMonth, 1))
-    fetchEvents(newYear, newMonth)
-  }
-
-  const goToNext = () => {
-    const newMonth = month === 11 ? 0 : month + 1
-    const newYear = month === 11 ? year + 1 : year
-    setMonth(newMonth)
-    setYear(newYear)
-    setSelectedDate(new Date(newYear, newMonth, 1))
-    fetchEvents(newYear, newMonth)
-  }
+  const monthDays = useMemo(
+    () => getMonthGridDays(date.getFullYear(), date.getMonth(), weekStartDay),
+    [date, weekStartDay]
+  )
 
-  const goToToday = () => {
-    const now = new Date()
-    const needsFetch = now.getMonth() !== month || now.getFullYear() !== year
-    setMonth(now.getMonth())
-    setYear(now.getFullYear())
-    setSelectedDate(now)
-    if (needsFetch) {
-      fetchEvents(now.getFullYear(), now.getMonth())
+  const body = (() => {
+    switch (view) {
+      case 'month':
+        return (
+          <MonthView
+            days={monthDays}
+            month={date.getMonth()}
+            eventsByDate={eventsByDate}
+            todayStr={todayStr}
+            selectedDateStr={dateStr}
+            showWeekends={preferences.showWeekends}
+            showWeekNumbers={preferences.showWeekNumbers}
+            actions={dayActions}
+            onSelectDate={selectDate}
+            onOpenDay={openDay}
+          />
+        )
+      case 'year':
+        return (
+          <YearView
+            year={date.getFullYear()}
+            eventsByDate={eventsByDate}
+            todayStr={todayStr}
+            selectedDateStr={dateStr}
+            onSelectDate={selectDate}
+            onOpenDay={openDay}
+          />
+        )
+      case 'schedule':
+        return (
+          <ScheduleView
+            days={days}
+            eventsByDate={eventsByDate}
+            todayStr={todayStr}
+            onSelectDate={openDay}
+          />
+        )
+      default:
+        return (
+          <TimeGridView
+            key={view}
+            days={days}
+            eventsByDate={eventsByDate}
+            todayStr={todayStr}
+            selectedDateStr={dateStr}
+            showWeekends={preferences.showWeekends}
+            actions={dayActions}
+            onSelectDate={setDate}
+            onOpenDay={openDay}
+          />
+        )
     }
-  }
+  })()
 
   return (
-    <div className="space-y-4">
-      <div className="grid gap-4 lg:grid-cols-3">
-        {/* Calendar grid */}
-        <div className="lg:col-span-2">
-          <Card>
-            <CardContent className="p-4">
-              {/* Header */}
-              <div className="flex items-center justify-between mb-4">
-                <div className="flex items-center gap-2">
-                  <Button
-                    size="icon"
-                    variant="outline"
-                    className="h-8 w-8"
-                    onClick={goToPrev}
-                    aria-label={t('previousMonth')}
-                  >
-                    <ChevronLeft className="h-4 w-4" />
-                  </Button>
-                  <Button
-                    size="icon"
-                    variant="outline"
-                    className="h-8 w-8"
-                    onClick={goToNext}
-                    aria-label={t('nextMonth')}
-                  >
-                    <ChevronRight className="h-4 w-4" />
-                  </Button>
-                  <h2 className="text-lg font-semibold ml-2">
-                    {MONTH_NAMES[month]} {year}
-                  </h2>
-                  {loading && (
-                    <Loader2 className="h-4 w-4 animate-spin text-muted-foreground ml-2" />
-                  )}
-                </div>
-                <div className="flex items-center gap-2">
-                  <Button size="sm" variant="outline" onClick={goToToday}>
-                    {t('today')}
-                  </Button>
-                  <Button size="sm" onClick={handleNewWorkOrder}>
-                    <Plus className="h-4 w-4 mr-1" />
-                    {t('workOrder')}
-                  </Button>
-                </div>
-              </div>
-
-              {/* Filters, each carrying what the month holds of that type */}
-              <div className="flex flex-wrap items-center gap-x-4 gap-y-2 mb-3 text-sm">
-                <label className="flex items-center gap-1.5 cursor-pointer">
-                  <Checkbox checked={showServices} onCheckedChange={(v) => setShowServices(!!v)} />
-                  <div className="h-2 w-2 rounded-full bg-blue-500" />
-                  <span className="text-muted-foreground">{t('filters.services')}</span>
-                  <span className="font-medium tabular-nums">{counts.services}</span>
-                </label>
-                <label className="flex items-center gap-1.5 cursor-pointer">
-                  <Checkbox
-                    checked={showReminders}
-                    onCheckedChange={(v) => setShowReminders(!!v)}
-                  />
-                  <div className="h-2 w-2 rounded-full bg-amber-500" />
-                  <span className="text-muted-foreground">{t('filters.reminders')}</span>
-                  <span className="font-medium tabular-nums">{counts.reminders}</span>
-                </label>
-                <label className="flex items-center gap-1.5 cursor-pointer">
-                  <Checkbox checked={showQuotes} onCheckedChange={(v) => setShowQuotes(!!v)} />
-                  <div className="h-2 w-2 rounded-full bg-violet-500" />
-                  <span className="text-muted-foreground">{t('filters.quotes')}</span>
-                  <span className="font-medium tabular-nums">{counts.quotes}</span>
-                </label>
-                <label className="flex items-center gap-1.5 cursor-pointer">
-                  <Checkbox checked={showMessages} onCheckedChange={(v) => setShowMessages(!!v)} />
-                  <div className="h-2 w-2 rounded-full bg-sky-500" />
-                  <span className="text-muted-foreground">{t('filters.messages')}</span>
-                  <span className="font-medium tabular-nums">{counts.messages}</span>
-                </label>
-                {counts.external > 0 && (
-                  <label className="flex items-center gap-1.5 cursor-pointer">
-                    <Checkbox
-                      checked={showExternal}
-                      onCheckedChange={(v) => setShowExternal(!!v)}
-                    />
-                    <div className="h-2 w-2 rounded-full bg-slate-400" />
-                    <span className="text-muted-foreground">{t('filters.external')}</span>
-                    <span className="font-medium tabular-nums">{counts.external}</span>
-                  </label>
-                )}
-                <span className="text-xs text-muted-foreground ml-auto">
-                  {t('monthTotal', { count: counts.total })}
-                </span>
-              </div>
-
-              {/* Weekday headers */}
-              <div className="grid grid-cols-7 gap-px mb-1">
-                {WEEKDAYS.map((day) => (
-                  <div
-                    key={day}
-                    className="text-center text-xs font-medium text-muted-foreground py-1"
-                  >
-                    {day}
-                  </div>
-                ))}
-              </div>
-
-              {/* Day grid */}
-              <div className="grid grid-cols-7 gap-px">
-                {days.map((date) => {
-                  const dateStr = toLocalDateStr(date)
-                  return (
-                    <CalendarDayCell
-                      key={dateStr}
-                      date={date}
-                      events={filteredEvents}
-                      isCurrentMonth={date.getMonth() === month}
-                      isToday={dateStr === todayStr}
-                      isSelected={dateStr === selectedDateStr}
-                      onClick={() => setSelectedDate(date)}
-                      onNewWorkOrder={() => handleMenuWorkOrder(dateStr)}
-                      onNewReminder={() => handleMenuReminder(dateStr)}
-                      onNewQuote={() => handleMenuQuote(dateStr)}
-                      onScheduleMessage={() => handleMenuMessage(dateStr)}
-                    />
-                  )
-                })}
-              </div>
-
-              <div className="hidden md:flex items-center gap-1.5 pt-3 text-xs text-muted-foreground">
-                <MousePointerClick className="h-3.5 w-3.5" />
-                {t('contextMenu.hint')}
-              </div>
-            </CardContent>
-          </Card>
-        </div>
-
-        {/* Event list sidebar */}
-        <div>
-          <Card>
-            <CardContent className="p-4">
-              <CalendarEventList
-                events={filteredEvents}
-                dateStr={selectedDateStr}
-                selectedDate={selectedDate}
-                currencyCode={currencyCode}
-              />
-            </CardContent>
-          </Card>
+    <EventPeekProvider currencyCode={currencyCode} onRefresh={refresh}>
+      {/*
+        A definite height, not flex-1: nothing above this in the layout has
+        one (the shell is min-h-svh), so a flexed calendar would grow to the
+        full 24-hour grid and hand scrolling to the page. The subtraction is
+        the header (4rem), the page padding (1rem) and, on phones, the
+        bottom nav (3.5rem). Short screens fall back to a page that scrolls.
+      */}
+      <div className="flex h-[calc(100svh-8.5rem)] min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-xl border border-card-edge bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.05),0_12px_32px_-16px_rgb(0_0_0/0.18)] md:h-[calc(100svh-5rem)]">
+        <CalendarToolbar
+          title={title}
+          view={view}
+          loading={loading}
+          sidebarOpen={preferences.sidebarOpen}
+          preferences={preferences}
+          onViewChange={setView}
+          onPrev={goPrev}
+          onNext={goNext}
+          onToday={goToday}
+          onToggleSidebar={() => updatePreferences({ sidebarOpen: !preferences.sidebarOpen })}
+          onPreferenceChange={updatePreferences}
+          onCreate={handleCreate}
+        />
+        <div className="flex min-h-0 flex-1">
+          {preferences.sidebarOpen && (
+            <CalendarSidebar
+              selectedDate={date}
+              month={date}
+              busyDates={busyDates}
+              filters={filters}
+              counts={counts}
+              total={total}
+              onSelectDate={selectDate}
+              onMonthChange={(m) => {
+                // Moving the mini calendar moves the main view with it, but a
+                // month that already holds the date is left alone.
+                if (m.getFullYear() !== date.getFullYear() || m.getMonth() !== date.getMonth()) {
+                  setDate(new Date(m.getFullYear(), m.getMonth(), 1))
+                }
+              }}
+              onFilterChange={(type, checked) =>
+                setFilters((current) => ({ ...current, [type]: checked }))
+              }
+            />
+          )}
+          <div className="flex min-h-0 min-w-0 flex-1 flex-col">{body}</div>
         </div>
       </div>
 
+      <Sheet open={daySheetOpen} onOpenChange={setDaySheetOpen}>
+        <SheetContent side="bottom" className="max-h-[80vh] overflow-y-auto">
+          <SheetHeader className="sr-only">
+            <SheetTitle>{format.dateTime(date, { dateStyle: 'full' })}</SheetTitle>
+          </SheetHeader>
+          <CalendarEventList events={eventsByDate.get(dateStr) ?? []} selectedDate={date} />
+          <div className="mt-3 flex justify-end">
+            <Button
+              size="sm"
+              variant="outline"
+              onClick={() => {
+                setDaySheetOpen(false)
+                openDay(date)
+              }}
+            >
+              {t('openDay', { date: format.dateTime(date, { day: 'numeric', month: 'short' }) })}
+            </Button>
+          </div>
+        </SheetContent>
+      </Sheet>
+
       <Dialog open={showDateChoice} onOpenChange={setShowDateChoice}>
         <DialogContent className="max-w-sm">
           <DialogHeader>
@@ -431,7 +548,7 @@ export default function CalendarClient({
           </DialogHeader>
           <p className="text-sm text-muted-foreground">
             {t('dateChoice.description', {
-              selectedDate: selectedDate.toLocaleDateString(undefined, {
+              selectedDate: format.dateTime(date, {
                 weekday: 'long',
                 year: 'numeric',
                 month: 'long',
@@ -441,12 +558,9 @@ export default function CalendarClient({
           </p>
           <div className="flex flex-col gap-2 pt-2">
             <Button onClick={() => handleDateChoice(true)}>
-              <CalendarDays className="h-4 w-4 mr-2" />
+              <CalendarDays className="mr-2 h-4 w-4" />
               {t('dateChoice.useSelected', {
-                date: selectedDate.toLocaleDateString(undefined, {
-                  month: 'short',
-                  day: 'numeric',
-                }),
+                date: format.dateTime(date, { month: 'short', day: 'numeric' }),
               })}
             </Button>
             <Button variant="outline" onClick={() => handleDateChoice(false)}>
@@ -461,7 +575,8 @@ export default function CalendarClient({
         onOpenChange={setShowReminderDialog}
         vehicles={reminderVehicles}
         defaultDueDate={menuDate}
-        onSaved={refreshMonth}
+        defaultDueTime={menuTime}
+        onSaved={refresh}
       />
 
       <NewQuoteDialog
@@ -475,7 +590,8 @@ export default function CalendarClient({
         onOpenChange={setShowMessageDialog}
         availableChannels={messageChannels}
         defaultDate={menuDateStr}
-        onSaved={refreshMonth}
+        defaultTime={menuTime}
+        onSaved={refresh}
       />
 
       <VehiclePickerDialog
@@ -484,8 +600,8 @@ export default function CalendarClient({
         vehicles={vehicles}
         customers={customers}
         title={t('selectVehicle')}
-        redirectQuery={workOrderDate ? { boardDate: workOrderDate } : undefined}
+        redirectQuery={workOrderQuery}
       />
-    </div>
+    </EventPeekProvider>
   )
 }

+ 142 - 237
src/features/calendar/Components/CalendarDayCell.tsx

@@ -1,268 +1,173 @@
 'use client'
 
-import { useRouter } from 'next/navigation'
+import { useLayoutEffect, useRef, useState, type MouseEvent } from 'react'
 import { useTranslations } from 'next-intl'
-import { Bell, ExternalLink, FileText, Send, Wrench } from 'lucide-react'
-import {
-  ContextMenu,
-  ContextMenuContent,
-  ContextMenuItem,
-  ContextMenuLabel,
-  ContextMenuSeparator,
-  ContextMenuSub,
-  ContextMenuSubContent,
-  ContextMenuSubTrigger,
-  ContextMenuTrigger,
-} from '@/components/ui/context-menu'
-import { useFormatDate } from '@/lib/use-format-date'
-import { getEventLink, toLocalDateStr } from './calendar-utils'
+import { useDayFormatter } from './useDayFormatter'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { cn } from '@/lib/utils'
+import { DayContextMenu, type DayActions } from './DayContextMenu'
+import { EventChip } from './EventChip'
+import { eventKey } from './calendar-utils'
 import type { CalendarEvent } from '../Actions/calendarActions'
 
-function getEventDotColor(event: CalendarEvent) {
-  if (event.type === 'external') return 'bg-slate-400'
-  if (event.type === 'service') {
-    switch (event.status) {
-      case 'completed':
-        return 'bg-emerald-500'
-      case 'in_progress':
-      case 'in-progress':
-        return 'bg-blue-500'
-      case 'waiting-parts':
-        return 'bg-orange-500'
-      default:
-        return 'bg-amber-500'
-    }
-  }
-  if (event.type === 'quote') {
-    return event.status === 'sent' ? 'bg-violet-500' : 'bg-violet-300'
-  }
-  if (event.type === 'message') {
-    switch (event.status) {
-      case 'sent':
-        return 'bg-teal-500'
-      case 'failed':
-        return 'bg-red-500'
-      default:
-        return 'bg-sky-500'
-    }
-  }
-  // reminder
-  switch (event.status) {
-    case 'completed':
-      return 'bg-emerald-500'
-    case 'overdue':
-      return 'bg-red-500'
-    default:
-      return 'bg-slate-400'
-  }
-}
-
-/** How many of the day's events the right-click menu lists before it stops */
-const MAX_MENU_EVENTS = 8
+/** Height of one chip row plus its gap, in pixels; the overflow maths depends on it. */
+const ROW_PX = 24
+/** Room the "+N more" line takes when it is shown. */
+const MORE_PX = 20
 
 interface CalendarDayCellProps {
   date: Date
+  dateStr: string
+  /** Already filtered and sorted for this day. */
   events: CalendarEvent[]
   isCurrentMonth: boolean
   isToday: boolean
   isSelected: boolean
-  onClick: () => void
-  onNewWorkOrder: () => void
-  onNewReminder: () => void
-  onNewQuote: () => void
-  onScheduleMessage: () => void
+  isWeekend: boolean
+  actions: DayActions
+  onSelect: () => void
+  /** Day number clicked: jump into that day. */
+  onOpenDay: () => void
 }
 
+/**
+ * One day of the month grid. Chips fill whatever height the row was given
+ * and the rest fold into a "+N more" line that opens the whole day in a
+ * popover, so a day with forty jobs stays the same height as an empty one.
+ */
 export function CalendarDayCell({
   date,
+  dateStr,
   events,
   isCurrentMonth,
   isToday,
   isSelected,
-  onClick,
-  onNewWorkOrder,
-  onNewReminder,
-  onNewQuote,
-  onScheduleMessage,
+  isWeekend,
+  actions,
+  onSelect,
+  onOpenDay,
 }: CalendarDayCellProps) {
   const t = useTranslations('calendar')
-  const router = useRouter()
-  const { formatDate } = useFormatDate()
-  const dateStr = toLocalDateStr(date)
-  const dayEvents = events.filter((e) => e.date === dateStr)
-  const serviceCount = dayEvents.filter((e) => e.type === 'service').length
-  const reminderCount = dayEvents.filter((e) => e.type === 'reminder').length
-  const quoteCount = dayEvents.filter((e) => e.type === 'quote').length
+  const format = useDayFormatter()
+  const cellRef = useRef<HTMLDivElement>(null)
+  const listRef = useRef<HTMLDivElement>(null)
+  // Measured height of the chip area; null until the browser has laid it out.
+  const [height, setHeight] = useState<number | null>(null)
+
+  useLayoutEffect(() => {
+    const el = listRef.current
+    if (!el) return
+    const measure = () => setHeight(el.clientHeight)
+    measure()
+    const observer = new ResizeObserver(measure)
+    observer.observe(el)
+    return () => observer.disconnect()
+  }, [])
+
+  const fitsAll = height === null ? 3 : Math.floor(height / ROW_PX)
+  // Only give a row up to the "more" line when something has to fold.
+  const withMore = height === null ? 2 : Math.floor((height - MORE_PX) / ROW_PX)
+  const visibleCount = events.length <= fitsAll ? events.length : Math.max(0, withMore)
+  const hidden = events.length - visibleCount
+  const dayNumber = date.getDate()
+  const dayLabel =
+    dayNumber === 1 ? format.dateTime(date, { day: 'numeric', month: 'short' }) : String(dayNumber)
+
+  const openDay = (e: MouseEvent) => {
+    e.stopPropagation()
+    onOpenDay()
+  }
 
   return (
-    <ContextMenu
-      // Right-clicking a day also selects it, so the sidebar and any dialog
-      // the menu opens are talking about the same date.
-      onOpenChange={(open) => {
-        if (open && !isSelected) onClick()
-      }}
+    <DayContextMenu
+      date={date}
+      dateStr={dateStr}
+      events={events}
+      actions={actions}
+      onOpen={() => !isSelected && onSelect()}
+      getAnchor={() => cellRef.current}
     >
-      <ContextMenuTrigger asChild>
-        <button
-          type="button"
-          onClick={onClick}
-          className={`
-            relative flex flex-col items-center justify-start p-1 min-h-[80px] w-full rounded-md text-sm transition-colors
-            ${isCurrentMonth ? '' : 'text-muted-foreground/40'}
-            ${isToday && !isSelected ? 'bg-primary/10' : ''}
-            ${isSelected ? 'ring-2 ring-primary bg-primary/5' : 'hover:bg-muted/50'}
-          `}
-        >
-          <span
-            className={`
-              flex h-6 w-6 items-center justify-center rounded-full text-xs
-              ${isToday ? 'bg-primary text-primary-foreground font-bold' : ''}
-            `}
+      <div
+        ref={cellRef}
+        role="gridcell"
+        aria-selected={isSelected}
+        aria-label={format.dateTime(date, { dateStyle: 'full' })}
+        onClick={onSelect}
+        onDoubleClick={openDay}
+        className={cn(
+          'group/day relative flex min-h-0 min-w-0 cursor-default flex-col border-b border-r border-border/70 transition-colors',
+          !isCurrentMonth && 'bg-muted/30',
+          isWeekend && isCurrentMonth && 'bg-muted/15',
+          isSelected ? 'bg-primary/5' : 'hover:bg-accent/40'
+        )}
+      >
+        {isSelected && (
+          <div className="pointer-events-none absolute inset-0 z-10 ring-2 ring-inset ring-primary/60" />
+        )}
+        <div className="flex h-7 shrink-0 items-center justify-center pt-1">
+          <button
+            type="button"
+            onClick={openDay}
+            className={cn(
+              'flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-xs font-medium tabular-nums transition-colors',
+              isToday
+                ? 'bg-primary font-bold text-primary-foreground'
+                : isCurrentMonth
+                  ? 'text-foreground hover:bg-accent'
+                  : 'text-muted-foreground/60 hover:bg-accent'
+            )}
+            aria-label={t('openDay', { date: format.dateTime(date, { dateStyle: 'long' }) })}
           >
-            {date.getDate()}
-          </span>
-          {dayEvents.length > 0 && (
-            <div className="flex flex-col gap-0.5 mt-0.5 w-full px-0.5">
-              {/* Show up to 3 mini event labels on larger screens */}
-              {dayEvents.slice(0, 2).map((event) => (
-                <div
-                  key={`${event.type}-${event.id}`}
-                  className={`hidden sm:flex items-center gap-1 rounded px-1 py-0 text-[10px] leading-tight truncate ${getEventBgColor(event)}`}
+            {dayLabel}
+          </button>
+        </div>
+        <div ref={listRef} className="flex min-h-0 flex-1 flex-col gap-0.5 px-1 pb-1">
+          {events.slice(0, visibleCount).map((event) => (
+            <EventChip
+              key={eventKey(event)}
+              event={event}
+              variant={event.time ? 'timed' : 'chip'}
+              compact
+            />
+          ))}
+          {hidden > 0 && (
+            <Popover>
+              <PopoverTrigger asChild>
+                <button
+                  type="button"
+                  onClick={(e) => e.stopPropagation()}
+                  className="h-5 shrink-0 rounded px-1.5 text-left text-[11px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
                 >
-                  <div className={`h-1 w-1 shrink-0 rounded-full ${getEventDotColor(event)}`} />
-                  <span className="truncate">{event.title}</span>
+                  {t('more', { count: hidden })}
+                </button>
+              </PopoverTrigger>
+              <PopoverContent
+                align="start"
+                className="w-72 p-2"
+                onOpenAutoFocus={(e) => e.preventDefault()}
+              >
+                <div className="mb-1.5 flex items-baseline justify-between px-1">
+                  <span className="text-sm font-semibold">
+                    {format.dateTime(date, { weekday: 'long', day: 'numeric', month: 'long' })}
+                  </span>
+                  <span className="text-xs text-muted-foreground">
+                    {t('dayTotal', { count: events.length })}
+                  </span>
                 </div>
-              ))}
-              {dayEvents.length > 2 && (
-                <span className="hidden sm:block text-[9px] text-muted-foreground px-1">
-                  {t('more', { count: dayEvents.length - 2 })}
-                </span>
-              )}
-              {/* Mobile: show dots */}
-              <div className="flex sm:hidden gap-0.5 justify-center flex-wrap">
-                {serviceCount > 0 && (
-                  <div className="flex items-center gap-0.5">
-                    <div className="h-1.5 w-1.5 rounded-full bg-blue-500" />
-                    {serviceCount > 1 && (
-                      <span className="text-[8px] text-muted-foreground">{serviceCount}</span>
-                    )}
-                  </div>
-                )}
-                {reminderCount > 0 && (
-                  <div className="flex items-center gap-0.5">
-                    <div className="h-1.5 w-1.5 rounded-full bg-amber-500" />
-                    {reminderCount > 1 && (
-                      <span className="text-[8px] text-muted-foreground">{reminderCount}</span>
-                    )}
-                  </div>
-                )}
-                {quoteCount > 0 && (
-                  <div className="flex items-center gap-0.5">
-                    <div className="h-1.5 w-1.5 rounded-full bg-violet-500" />
-                    {quoteCount > 1 && (
-                      <span className="text-[8px] text-muted-foreground">{quoteCount}</span>
-                    )}
-                  </div>
-                )}
-              </div>
-            </div>
-          )}
-        </button>
-      </ContextMenuTrigger>
-
-      <ContextMenuContent className="min-w-56">
-        <ContextMenuLabel className="text-xs font-normal text-muted-foreground">
-          {formatDate(date)}
-        </ContextMenuLabel>
-        <ContextMenuSeparator />
-        <ContextMenuItem onClick={onNewWorkOrder}>
-          <Wrench className="mr-2 h-4 w-4" />
-          {t('contextMenu.newWorkOrder')}
-        </ContextMenuItem>
-        <ContextMenuItem onClick={onNewReminder}>
-          <Bell className="mr-2 h-4 w-4" />
-          {t('contextMenu.newReminder')}
-        </ContextMenuItem>
-        <ContextMenuItem onClick={onNewQuote}>
-          <FileText className="mr-2 h-4 w-4" />
-          {t('contextMenu.newQuote')}
-        </ContextMenuItem>
-        <ContextMenuItem onClick={onScheduleMessage}>
-          <Send className="mr-2 h-4 w-4" />
-          {t('contextMenu.scheduleMessage')}
-        </ContextMenuItem>
-        {dayEvents.length > 0 && (
-          <>
-            <ContextMenuSeparator />
-            <ContextMenuSub>
-              <ContextMenuSubTrigger>
-                <ExternalLink className="mr-2 h-4 w-4" />
-                {t('contextMenu.openEvent', { count: dayEvents.length })}
-              </ContextMenuSubTrigger>
-              <ContextMenuSubContent className="max-w-72">
-                {dayEvents.slice(0, MAX_MENU_EVENTS).map((event) => (
-                  <ContextMenuItem
-                    key={`${event.type}-${event.id}`}
-                    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
-                      className={`mr-2 h-1.5 w-1.5 shrink-0 rounded-full ${getEventDotColor(event)}`}
+                <div className="flex max-h-80 flex-col gap-0.5 overflow-y-auto">
+                  {events.map((event) => (
+                    <EventChip
+                      key={eventKey(event)}
+                      event={event}
+                      variant={event.time ? 'timed' : 'chip'}
                     />
-                    <span className="truncate">{event.title}</span>
-                  </ContextMenuItem>
-                ))}
-                {dayEvents.length > MAX_MENU_EVENTS && (
-                  <ContextMenuItem disabled>
-                    {t('more', { count: dayEvents.length - MAX_MENU_EVENTS })}
-                  </ContextMenuItem>
-                )}
-              </ContextMenuSubContent>
-            </ContextMenuSub>
-          </>
-        )}
-      </ContextMenuContent>
-    </ContextMenu>
+                  ))}
+                </div>
+              </PopoverContent>
+            </Popover>
+          )}
+        </div>
+      </div>
+    </DayContextMenu>
   )
 }
-
-function getEventBgColor(event: CalendarEvent) {
-  if (event.type === 'external') {
-    return 'bg-slate-500/10 text-slate-600 dark:text-slate-400 italic'
-  }
-  if (event.type === 'service') {
-    switch (event.status) {
-      case 'completed':
-        return 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
-      case 'in_progress':
-      case 'in-progress':
-        return 'bg-blue-500/10 text-blue-700 dark:text-blue-400'
-      case 'waiting-parts':
-        return 'bg-orange-500/10 text-orange-700 dark:text-orange-400'
-      default:
-        return 'bg-amber-500/10 text-amber-700 dark:text-amber-400'
-    }
-  }
-  if (event.type === 'quote') {
-    return 'bg-violet-500/10 text-violet-700 dark:text-violet-400'
-  }
-  if (event.type === 'message') {
-    return event.status === 'failed'
-      ? 'bg-red-500/10 text-red-700 dark:text-red-400'
-      : 'bg-sky-500/10 text-sky-700 dark:text-sky-400'
-  }
-  switch (event.status) {
-    case 'overdue':
-      return 'bg-red-500/10 text-red-700 dark:text-red-400'
-    default:
-      return 'bg-slate-500/10 text-slate-600 dark:text-slate-400'
-  }
-}

+ 26 - 368
src/features/calendar/Components/CalendarEventList.tsx

@@ -1,389 +1,47 @@
 'use client'
 
-import Link from 'next/link'
-import { useState } from 'react'
-import { useFormatter, useTranslations } from 'next-intl'
-import { CalendarClock, ExternalLink } from 'lucide-react'
-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 { useFormatCurrency } from '@/components/currency-settings-context'
+import { useTranslations } from 'next-intl'
+import { useDayFormatter } from './useDayFormatter'
+import { EventChip } from './EventChip'
+import { eventKey } from './calendar-utils'
 import type { CalendarEvent } from '../Actions/calendarActions'
 
-function getStatusColor(event: CalendarEvent) {
-  if (event.type === 'external') return 'bg-slate-400'
-  if (event.type === 'service') {
-    switch (event.status) {
-      case 'completed':
-        return 'bg-emerald-500'
-      case 'in_progress':
-      case 'in-progress':
-        return 'bg-blue-500'
-      case 'waiting-parts':
-        return 'bg-orange-500'
-      default:
-        return 'bg-amber-500'
-    }
-  }
-  if (event.type === 'quote') {
-    return event.status === 'sent' ? 'bg-violet-500' : 'bg-violet-300'
-  }
-  if (event.type === 'message') {
-    switch (event.status) {
-      case 'sent':
-        return 'bg-teal-500'
-      case 'failed':
-        return 'bg-red-500'
-      default:
-        return 'bg-sky-500'
-    }
-  }
-  switch (event.status) {
-    case 'completed':
-      return 'bg-emerald-500'
-    case 'overdue':
-      return 'bg-red-500'
-    default:
-      return 'bg-slate-400'
-  }
-}
-
-function getStatusBadge(event: CalendarEvent, t: (key: string) => string) {
-  if (event.type === 'external') {
-    return (
-      <Badge variant="outline" className="text-xs text-muted-foreground">
-        {t('events.status.busy')}
-      </Badge>
-    )
-  }
-  if (event.type === 'service') {
-    switch (event.status) {
-      case 'completed':
-        return (
-          <Badge
-            variant="outline"
-            className="text-emerald-600 border-emerald-300 text-[10px] px-1.5 py-0"
-          >
-            {t('events.status.completed')}
-          </Badge>
-        )
-      case 'in_progress':
-      case 'in-progress':
-        return (
-          <Badge
-            variant="outline"
-            className="text-blue-600 border-blue-300 text-[10px] px-1.5 py-0"
-          >
-            {t('events.status.inProgress')}
-          </Badge>
-        )
-      case 'waiting-parts':
-        return (
-          <Badge
-            variant="outline"
-            className="text-orange-600 border-orange-300 text-[10px] px-1.5 py-0"
-          >
-            {t('events.status.waitingParts')}
-          </Badge>
-        )
-      default:
-        return (
-          <Badge
-            variant="outline"
-            className="text-amber-600 border-amber-300 text-[10px] px-1.5 py-0"
-          >
-            {t('events.status.pending')}
-          </Badge>
-        )
-    }
-  }
-  if (event.type === 'message') {
-    switch (event.status) {
-      case 'sent':
-        return (
-          <Badge
-            variant="outline"
-            className="text-teal-600 border-teal-300 text-[10px] px-1.5 py-0"
-          >
-            {t('events.status.sent')}
-          </Badge>
-        )
-      case 'failed':
-        return (
-          <Badge variant="destructive" className="text-[10px] px-1.5 py-0">
-            {t('events.status.failed')}
-          </Badge>
-        )
-      default:
-        return (
-          <Badge variant="outline" className="text-sky-600 border-sky-300 text-[10px] px-1.5 py-0">
-            {t('events.status.queued')}
-          </Badge>
-        )
-    }
-  }
-  if (event.type === 'quote') {
-    switch (event.status) {
-      case 'sent':
-        return (
-          <Badge
-            variant="outline"
-            className="text-violet-600 border-violet-300 text-[10px] px-1.5 py-0"
-          >
-            {t('events.status.sent')}
-          </Badge>
-        )
-      case 'approved':
-        return (
-          <Badge
-            variant="outline"
-            className="text-emerald-600 border-emerald-300 text-[10px] px-1.5 py-0"
-          >
-            {t('events.status.approved')}
-          </Badge>
-        )
-      default:
-        return (
-          <Badge
-            variant="outline"
-            className="text-violet-600 border-violet-300 text-[10px] px-1.5 py-0"
-          >
-            {t('events.status.draft')}
-          </Badge>
-        )
-    }
-  }
-  switch (event.status) {
-    case 'completed':
-      return (
-        <Badge
-          variant="outline"
-          className="text-emerald-600 border-emerald-300 text-[10px] px-1.5 py-0"
-        >
-          {t('events.status.done')}
-        </Badge>
-      )
-    case 'overdue':
-      return (
-        <Badge variant="destructive" className="text-[10px] px-1.5 py-0">
-          {t('events.status.overdue')}
-        </Badge>
-      )
-    default:
-      return (
-        <Badge variant="outline" className="text-[10px] px-1.5 py-0">
-          {t('events.status.upcoming')}
-        </Badge>
-      )
-  }
-}
-
-function getTypeBadge(type: CalendarEvent['type'], t: (key: string) => string) {
-  switch (type) {
-    case 'service':
-      return (
-        <Badge
-          variant="secondary"
-          className="shrink-0 text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-700 dark:text-blue-400"
-        >
-          {t('events.type.service')}
-        </Badge>
-      )
-    case 'reminder':
-      return (
-        <Badge
-          variant="secondary"
-          className="shrink-0 text-[10px] px-1.5 py-0 bg-amber-500/10 text-amber-700 dark:text-amber-400"
-        >
-          {t('events.type.reminder')}
-        </Badge>
-      )
-    case 'quote':
-      return (
-        <Badge
-          variant="secondary"
-          className="shrink-0 text-[10px] px-1.5 py-0 bg-violet-500/10 text-violet-700 dark:text-violet-400"
-        >
-          {t('events.type.quote')}
-        </Badge>
-      )
-    case 'message':
-      return (
-        <Badge
-          variant="secondary"
-          className="shrink-0 text-[10px] px-1.5 py-0 bg-sky-500/10 text-sky-700 dark:text-sky-400"
-        >
-          {t('events.type.message')}
-        </Badge>
-      )
-  }
-}
-
 interface CalendarEventListProps {
+  /** Already filtered and sorted for the day. */
   events: CalendarEvent[]
-  dateStr: string // YYYY-MM-DD
   selectedDate: Date
-  currencyCode: string
 }
 
-export function CalendarEventList({
-  events,
-  dateStr,
-  selectedDate,
-  currencyCode,
-}: CalendarEventListProps) {
-  const formatCurrency = useFormatCurrency()
+/**
+ * One day as a plain list. The desktop views peek at events in place; this
+ * is what a phone shows in a sheet when a day is tapped.
+ */
+export function CalendarEventList({ events, selectedDate }: CalendarEventListProps) {
   const t = useTranslations('calendar')
-  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>
-    </>
-  )
+  const format = useDayFormatter()
 
   return (
     <div className="space-y-3">
-      <p className="text-sm font-semibold">{formatDateHeader(selectedDate)}</p>
-
-      {dayEvents.length === 0 ? (
-        <div className="text-center py-8">
-          <p className="text-sm text-muted-foreground mb-1">{t('events.noEvents')}</p>
+      <p className="text-sm font-semibold">
+        {format.dateTime(selectedDate, {
+          weekday: 'long',
+          day: 'numeric',
+          month: 'long',
+          year: 'numeric',
+        })}
+      </p>
+      {events.length === 0 ? (
+        <div className="py-8 text-center">
+          <p className="mb-1 text-sm text-muted-foreground">{t('events.noEvents')}</p>
           <p className="text-xs text-muted-foreground">{t('events.selectDay')}</p>
         </div>
       ) : (
-        <div className="space-y-2">
-          {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 className="flex flex-col gap-0.5">
+          {events.map((event) => (
+            <EventChip key={eventKey(event)} event={event} variant="row" />
+          ))}
         </div>
       )}
-      <ExternalEventDialog event={external} onClose={() => setExternal(null)} />
     </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>
-  )
-}

+ 129 - 0
src/features/calendar/Components/CalendarSidebar.tsx

@@ -0,0 +1,129 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { Calendar } from '@/components/ui/calendar'
+import { Checkbox } from '@/components/ui/checkbox'
+import { cn } from '@/lib/utils'
+import { typePalette } from './calendar-utils'
+import type { CalendarEventType } from '../Actions/calendarActions'
+
+export type TypeFilters = Record<CalendarEventType, boolean>
+
+const FILTER_ORDER: CalendarEventType[] = ['service', 'reminder', 'quote', 'message', 'external']
+
+const FILTER_LABEL: Record<CalendarEventType, string> = {
+  service: 'services',
+  reminder: 'reminders',
+  quote: 'quotes',
+  message: 'messages',
+  external: 'external',
+}
+
+interface CalendarSidebarProps {
+  selectedDate: Date
+  /** The month the mini calendar shows; follows the main view. */
+  month: Date
+  busyDates: Date[]
+  filters: TypeFilters
+  counts: Record<CalendarEventType, number>
+  total: number
+  onSelectDate: (date: Date) => void
+  onMonthChange: (date: Date) => void
+  onFilterChange: (type: CalendarEventType, checked: boolean) => void
+}
+
+/**
+ * The left rail: a mini month to jump around by, and the list of what the
+ * calendar draws with a checkbox and a count for each, the way a desktop
+ * calendar lists its calendars.
+ */
+export function CalendarSidebar({
+  selectedDate,
+  month,
+  busyDates,
+  filters,
+  counts,
+  total,
+  onSelectDate,
+  onMonthChange,
+  onFilterChange,
+}: CalendarSidebarProps) {
+  const t = useTranslations('calendar')
+
+  return (
+    <aside className="hidden w-64 shrink-0 flex-col gap-2 overflow-y-auto border-r border-border/70 lg:flex">
+      <Calendar
+        mode="single"
+        selected={selectedDate}
+        onSelect={(date) => date && onSelectDate(date)}
+        month={month}
+        onMonthChange={onMonthChange}
+        modifiers={{ busy: busyDates }}
+        modifiersClassNames={{ busy: '[&_button]:font-semibold [&_button]:text-primary' }}
+        className="w-full bg-transparent p-3 [--cell-size:2rem]"
+      />
+
+      <div className="px-3 pb-3">
+        <div className="mb-1.5 flex items-baseline justify-between px-1">
+          <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+            {t('sidebar.calendars')}
+          </p>
+          <span className="text-[11px] tabular-nums text-muted-foreground">
+            {t('monthTotal', { count: total })}
+          </span>
+        </div>
+        <ul className="space-y-0.5">
+          {FILTER_ORDER.map((type) => {
+            if (type === 'external' && counts.external === 0) return null
+            const palette = typePalette(type)
+            return (
+              <li key={type}>
+                <label
+                  className={cn(
+                    'flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent',
+                    !filters[type] && 'text-muted-foreground'
+                  )}
+                >
+                  <Checkbox
+                    checked={filters[type]}
+                    onCheckedChange={(checked) => onFilterChange(type, !!checked)}
+                    aria-label={t(`filters.${FILTER_LABEL[type]}`)}
+                  />
+                  <span className={cn('h-2.5 w-2.5 shrink-0 rounded-sm', palette.dot)} />
+                  <span className="min-w-0 flex-1 truncate">
+                    {t(`filters.${FILTER_LABEL[type]}`)}
+                  </span>
+                  <span className="text-xs tabular-nums text-muted-foreground">{counts[type]}</span>
+                </label>
+              </li>
+            )
+          })}
+        </ul>
+
+        <div className="mt-4 space-y-1 px-1">
+          <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+            {t('sidebar.legend')}
+          </p>
+          <ul className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs text-muted-foreground">
+            <li className="flex items-center gap-1.5">
+              <span className="h-2 w-2 rounded-full bg-emerald-500" />
+              {t('events.status.completed')}
+            </li>
+            <li className="flex items-center gap-1.5">
+              <span className="h-2 w-2 rounded-full bg-orange-500" />
+              {t('events.status.waitingParts')}
+            </li>
+            <li className="flex items-center gap-1.5">
+              <span className="h-2 w-2 rounded-full bg-red-500" />
+              {t('events.status.overdue')}
+            </li>
+            <li className="flex items-center gap-1.5">
+              <span className="h-2 w-2 rounded-full bg-red-500" />
+              {t('events.status.failed')}
+            </li>
+          </ul>
+        </div>
+      </div>
+    </aside>
+  )
+}

+ 200 - 0
src/features/calendar/Components/CalendarToolbar.tsx

@@ -0,0 +1,200 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import {
+  Bell,
+  ChevronDown,
+  ChevronLeft,
+  ChevronRight,
+  FileText,
+  Loader2,
+  PanelLeft,
+  Plus,
+  Send,
+  Wrench,
+} from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuCheckboxItem,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuRadioGroup,
+  DropdownMenuRadioItem,
+  DropdownMenuSeparator,
+  DropdownMenuShortcut,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { cn } from '@/lib/utils'
+import type { CalendarView } from '../Lib/calendar-range'
+import type { CalendarPreferences } from './useCalendarPreferences'
+
+export type CreateKind = 'workOrder' | 'reminder' | 'quote' | 'message'
+
+/** The keys the view switcher shows and the keyboard listens for. */
+export const VIEW_SHORTCUTS: Record<CalendarView, string> = {
+  day: 'D',
+  week: 'W',
+  month: 'M',
+  year: 'Y',
+  schedule: 'A',
+  fourDays: 'X',
+}
+
+const VIEW_ORDER: CalendarView[] = ['day', 'week', 'month', 'year', 'schedule', 'fourDays']
+
+interface CalendarToolbarProps {
+  title: string
+  view: CalendarView
+  loading: boolean
+  sidebarOpen: boolean
+  preferences: CalendarPreferences
+  onViewChange: (view: CalendarView) => void
+  onPrev: () => void
+  onNext: () => void
+  onToday: () => void
+  onToggleSidebar: () => void
+  onPreferenceChange: (patch: Partial<CalendarPreferences>) => void
+  onCreate: (kind: CreateKind) => void
+}
+
+/**
+ * The strip above the grid: today, back and forward, what is showing, and
+ * on the right the view switcher and the create menu. Laid out the way
+ * every desktop calendar lays it out, so nobody has to learn it.
+ */
+export function CalendarToolbar({
+  title,
+  view,
+  loading,
+  sidebarOpen,
+  preferences,
+  onViewChange,
+  onPrev,
+  onNext,
+  onToday,
+  onToggleSidebar,
+  onPreferenceChange,
+  onCreate,
+}: CalendarToolbarProps) {
+  const t = useTranslations('calendar')
+
+  return (
+    <div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/70 px-2 py-2 sm:px-3">
+      <Button
+        variant="ghost"
+        size="icon"
+        className="hidden h-9 w-9 lg:inline-flex"
+        onClick={onToggleSidebar}
+        aria-label={sidebarOpen ? t('toolbar.hideSidebar') : t('toolbar.showSidebar')}
+        aria-pressed={sidebarOpen}
+      >
+        <PanelLeft className="h-4 w-4" />
+      </Button>
+
+      <Button variant="outline" size="sm" className="h-9" onClick={onToday}>
+        {t('today')}
+      </Button>
+      <div className="flex items-center">
+        <Button
+          variant="ghost"
+          size="icon"
+          className="h-9 w-9"
+          onClick={onPrev}
+          aria-label={t('toolbar.previous')}
+        >
+          <ChevronLeft className="h-4 w-4" />
+        </Button>
+        <Button
+          variant="ghost"
+          size="icon"
+          className="h-9 w-9"
+          onClick={onNext}
+          aria-label={t('toolbar.next')}
+        >
+          <ChevronRight className="h-4 w-4" />
+        </Button>
+      </div>
+      <h2 className="min-w-0 truncate text-lg font-semibold sm:text-xl" aria-live="polite">
+        {title}
+      </h2>
+      <Loader2
+        className={cn(
+          'h-4 w-4 animate-spin text-muted-foreground transition-opacity',
+          loading ? 'opacity-100' : 'opacity-0'
+        )}
+        aria-hidden={!loading}
+      />
+
+      <div className="ml-auto flex items-center gap-2">
+        <DropdownMenu>
+          <DropdownMenuTrigger asChild>
+            <Button variant="outline" size="sm" className="h-9 min-w-[7rem] justify-between">
+              {t(`views.${view}`)}
+              <ChevronDown className="ml-1 h-4 w-4 opacity-60" />
+            </Button>
+          </DropdownMenuTrigger>
+          <DropdownMenuContent align="end" className="w-56">
+            <DropdownMenuRadioGroup
+              value={view}
+              onValueChange={(value) => onViewChange(value as CalendarView)}
+            >
+              {VIEW_ORDER.map((v) => (
+                <DropdownMenuRadioItem key={v} value={v}>
+                  {t(`views.${v}`)}
+                  <DropdownMenuShortcut>{VIEW_SHORTCUTS[v]}</DropdownMenuShortcut>
+                </DropdownMenuRadioItem>
+              ))}
+            </DropdownMenuRadioGroup>
+            <DropdownMenuSeparator />
+            <DropdownMenuCheckboxItem
+              checked={preferences.showWeekends}
+              onCheckedChange={(checked) => onPreferenceChange({ showWeekends: !!checked })}
+            >
+              {t('options.showWeekends')}
+            </DropdownMenuCheckboxItem>
+            <DropdownMenuCheckboxItem
+              checked={preferences.showCompleted}
+              onCheckedChange={(checked) => onPreferenceChange({ showCompleted: !!checked })}
+            >
+              {t('options.showCompleted')}
+            </DropdownMenuCheckboxItem>
+            <DropdownMenuCheckboxItem
+              checked={preferences.showWeekNumbers}
+              onCheckedChange={(checked) => onPreferenceChange({ showWeekNumbers: !!checked })}
+            >
+              {t('options.showWeekNumbers')}
+            </DropdownMenuCheckboxItem>
+          </DropdownMenuContent>
+        </DropdownMenu>
+
+        <DropdownMenu>
+          <DropdownMenuTrigger asChild>
+            <Button size="sm" className="h-9">
+              <Plus className="h-4 w-4 sm:mr-1.5" />
+              <span className="hidden sm:inline">{t('toolbar.create')}</span>
+            </Button>
+          </DropdownMenuTrigger>
+          <DropdownMenuContent align="end" className="w-52">
+            <DropdownMenuItem onClick={() => onCreate('workOrder')}>
+              <Wrench className="mr-2 h-4 w-4" />
+              {t('contextMenu.newWorkOrder')}
+            </DropdownMenuItem>
+            <DropdownMenuItem onClick={() => onCreate('reminder')}>
+              <Bell className="mr-2 h-4 w-4" />
+              {t('contextMenu.newReminder')}
+            </DropdownMenuItem>
+            <DropdownMenuItem onClick={() => onCreate('quote')}>
+              <FileText className="mr-2 h-4 w-4" />
+              {t('contextMenu.newQuote')}
+            </DropdownMenuItem>
+            <DropdownMenuItem onClick={() => onCreate('message')}>
+              <Send className="mr-2 h-4 w-4" />
+              {t('contextMenu.scheduleMessage')}
+            </DropdownMenuItem>
+          </DropdownMenuContent>
+        </DropdownMenu>
+      </div>
+    </div>
+  )
+}

+ 134 - 0
src/features/calendar/Components/DayContextMenu.tsx

@@ -0,0 +1,134 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { useTranslations } from 'next-intl'
+import { Bell, ExternalLink, FileText, Send, Wrench } from 'lucide-react'
+import {
+  ContextMenu,
+  ContextMenuContent,
+  ContextMenuItem,
+  ContextMenuLabel,
+  ContextMenuSeparator,
+  ContextMenuSub,
+  ContextMenuSubContent,
+  ContextMenuSubTrigger,
+  ContextMenuTrigger,
+} from '@/components/ui/context-menu'
+import { useDateSettings } from '@/components/date-settings-context'
+import { useFormatDate } from '@/lib/use-format-date'
+import { formatClock } from '@/features/workboard/utils/clock'
+import { timeToMinutes } from '../Lib/calendar-range'
+import { eventKey, eventPalette } from './calendar-utils'
+import { useEventPeek } from './EventPeek'
+import type { CalendarEvent } from '../Actions/calendarActions'
+
+/** What a right-click on a day (or a time slot) can start. */
+export interface DayActions {
+  /** `time` is HH:MM when the click landed on a time slot. */
+  onNewWorkOrder: (dateStr: string, time?: string) => void
+  onNewReminder: (dateStr: string, time?: string) => void
+  onNewQuote: (dateStr: string, time?: string) => void
+  onScheduleMessage: (dateStr: string, time?: string) => void
+}
+
+/** How many of the day's events the right-click menu lists before it stops */
+const MAX_MENU_EVENTS = 8
+
+/**
+ * The right-click menu every day carries: create something on that day, or
+ * open one of the things already on it. The time grid passes the slot's time
+ * so a new work order lands at that hour.
+ */
+export function DayContextMenu({
+  date,
+  dateStr,
+  events,
+  actions,
+  time,
+  onOpen,
+  getAnchor,
+  children,
+}: {
+  date: Date
+  dateStr: string
+  events: CalendarEvent[]
+  actions: DayActions
+  /** HH:MM of the slot the menu was opened on, when there is one. */
+  time?: string | null
+  /** Called when the menu opens, so the day it is about becomes the selected one. */
+  onOpen?: () => void
+  /** The element a peek opened from the menu anchors to; the menu item itself is gone by then. */
+  getAnchor?: () => HTMLElement | null
+  children: ReactNode
+}) {
+  const t = useTranslations('calendar')
+  const { formatDate } = useFormatDate()
+  const { timeFormat } = useDateSettings()
+  const { openPeek } = useEventPeek()
+  return (
+    <ContextMenu onOpenChange={(open) => open && onOpen?.()}>
+      <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
+      <ContextMenuContent className="min-w-56">
+        <ContextMenuLabel className="text-xs font-normal text-muted-foreground">
+          {formatDate(date)}
+          {time ? ` · ${formatClock(timeToMinutes(time), timeFormat)}` : ''}
+        </ContextMenuLabel>
+        <ContextMenuSeparator />
+        <ContextMenuItem onClick={() => actions.onNewWorkOrder(dateStr, time ?? undefined)}>
+          <Wrench className="mr-2 h-4 w-4" />
+          {time
+            ? t('contextMenu.newWorkOrderAt', {
+                time: formatClock(timeToMinutes(time), timeFormat),
+              })
+            : t('contextMenu.newWorkOrder')}
+        </ContextMenuItem>
+        <ContextMenuItem onClick={() => actions.onNewReminder(dateStr, time ?? undefined)}>
+          <Bell className="mr-2 h-4 w-4" />
+          {t('contextMenu.newReminder')}
+        </ContextMenuItem>
+        <ContextMenuItem onClick={() => actions.onNewQuote(dateStr, time ?? undefined)}>
+          <FileText className="mr-2 h-4 w-4" />
+          {t('contextMenu.newQuote')}
+        </ContextMenuItem>
+        <ContextMenuItem onClick={() => actions.onScheduleMessage(dateStr, time ?? undefined)}>
+          <Send className="mr-2 h-4 w-4" />
+          {t('contextMenu.scheduleMessage')}
+        </ContextMenuItem>
+        {events.length > 0 && (
+          <>
+            <ContextMenuSeparator />
+            <ContextMenuSub>
+              <ContextMenuSubTrigger>
+                <ExternalLink className="mr-2 h-4 w-4" />
+                {t('contextMenu.openEvent', { count: events.length })}
+              </ContextMenuSubTrigger>
+              <ContextMenuSubContent className="max-w-72">
+                {events.slice(0, MAX_MENU_EVENTS).map((event) => (
+                  <ContextMenuItem
+                    key={eventKey(event)}
+                    onClick={(e) => {
+                      const fallback = e.currentTarget as HTMLElement
+                      // The menu closes on click and takes the item with it, so
+                      // the peek hangs off the day instead.
+                      setTimeout(() => openPeek(event, getAnchor?.() ?? fallback), 0)
+                    }}
+                  >
+                    <div
+                      className={`mr-2 h-1.5 w-1.5 shrink-0 rounded-full ${eventPalette(event).dot}`}
+                    />
+                    <span className="truncate">{event.title}</span>
+                  </ContextMenuItem>
+                ))}
+                {events.length > MAX_MENU_EVENTS && (
+                  <ContextMenuItem disabled>
+                    {t('more', { count: events.length - MAX_MENU_EVENTS })}
+                  </ContextMenuItem>
+                )}
+              </ContextMenuSubContent>
+            </ContextMenuSub>
+          </>
+        )}
+      </ContextMenuContent>
+    </ContextMenu>
+  )
+}

+ 162 - 0
src/features/calendar/Components/EventChip.tsx

@@ -0,0 +1,162 @@
+'use client'
+
+import type { CSSProperties, MouseEvent, ReactNode } from 'react'
+import { Check } from 'lucide-react'
+import { useDateSettings } from '@/components/date-settings-context'
+import { formatClock } from '@/features/workboard/utils/clock'
+import { cn } from '@/lib/utils'
+import { timeToMinutes } from '../Lib/calendar-range'
+import { eventPalette, isEventDone } from './calendar-utils'
+import { EventContextMenu } from './EventContextMenu'
+import { useEventPeek } from './EventPeek'
+import type { CalendarEvent } from '../Actions/calendarActions'
+
+/**
+ * The one drawing of an event, in the shapes the views need:
+ *
+ * - `chip`  a tinted bar in a month cell or the all-day row
+ * - `timed` a dot, the start time and the title, for a timed event in a month cell
+ * - `block` a solid block on the time grid; the parent positions it
+ * - `row`   a list row for the schedule view and the day sheet
+ *
+ * Every shape opens the same peek card on click and the same menu on
+ * right-click, so nothing here links out.
+ */
+export function EventChip({
+  event,
+  variant,
+  style,
+  className,
+  compact = false,
+}: {
+  event: CalendarEvent
+  variant: 'chip' | 'timed' | 'block' | 'row'
+  style?: CSSProperties
+  className?: string
+  /** Blocks shorter than a line drop the time and shrink the text. */
+  compact?: boolean
+}) {
+  const { timeFormat } = useDateSettings()
+  const { openPeek } = useEventPeek()
+  const palette = eventPalette(event)
+  const done = isEventDone(event)
+  const start = event.time ? formatClock(timeToMinutes(event.time), timeFormat) : null
+  const end = event.endTime ? formatClock(timeToMinutes(event.endTime), timeFormat) : null
+
+  const onClick = (e: MouseEvent<HTMLButtonElement>) => {
+    e.stopPropagation()
+    openPeek(event, e.currentTarget)
+  }
+  // The day underneath has a menu of its own; a right-click on the event
+  // must open the event's, not both.
+  const onContextMenu = (e: MouseEvent<HTMLButtonElement>) => e.stopPropagation()
+  const label = start ? `${start} ${event.title}` : event.title
+
+  let button: ReactNode
+
+  if (variant === 'timed') {
+    button = (
+      <button
+        type="button"
+        onClick={onClick}
+        onContextMenu={onContextMenu}
+        title={label}
+        style={style}
+        className={cn(
+          'flex h-[22px] w-full min-w-0 items-center gap-1.5 rounded px-1.5 text-left text-xs leading-none transition-colors hover:bg-accent',
+          done && 'opacity-60',
+          className
+        )}
+      >
+        <span className={cn('h-2 w-2 shrink-0 rounded-full', palette.dot)} />
+        {start && (
+          <span className="hidden shrink-0 tabular-nums text-muted-foreground sm:inline">
+            {start}
+          </span>
+        )}
+        <span className={cn('truncate font-medium', done && 'line-through')}>{event.title}</span>
+      </button>
+    )
+  } else if (variant === 'chip') {
+    button = (
+      <button
+        type="button"
+        onClick={onClick}
+        onContextMenu={onContextMenu}
+        title={label}
+        style={style}
+        className={cn(
+          'flex h-[22px] w-full min-w-0 items-center gap-1.5 rounded px-1.5 text-left text-xs font-medium leading-none transition-colors',
+          palette.chip,
+          done && 'opacity-60',
+          className
+        )}
+      >
+        {done && <Check className="h-3 w-3 shrink-0" />}
+        <span className={cn('truncate', done && 'line-through')}>{event.title}</span>
+        {start && !compact && (
+          <span className="ml-auto shrink-0 tabular-nums opacity-70">{start}</span>
+        )}
+      </button>
+    )
+  } else if (variant === 'block') {
+    button = (
+      <button
+        type="button"
+        onClick={onClick}
+        onContextMenu={onContextMenu}
+        title={end ? `${start} – ${end} ${event.title}` : label}
+        style={style}
+        className={cn(
+          'absolute flex flex-col overflow-hidden rounded-md border px-1.5 py-1 text-left text-xs leading-tight shadow-sm transition-[filter,box-shadow] hover:z-20 hover:shadow-md',
+          palette.block,
+          done && 'opacity-70',
+          compact && 'justify-center py-0',
+          className
+        )}
+      >
+        <span className={cn('truncate font-semibold', done && 'line-through')}>
+          {compact && start ? `${start} ` : ''}
+          {event.title}
+        </span>
+        {!compact && (
+          <span className="truncate text-[11px] opacity-90">
+            {start}
+            {end ? ` – ${end}` : ''}
+            {event.vehicleLabel ? ` · ${event.vehicleLabel}` : ''}
+          </span>
+        )}
+      </button>
+    )
+  } else {
+    button = (
+      <button
+        type="button"
+        onClick={onClick}
+        onContextMenu={onContextMenu}
+        style={style}
+        className={cn(
+          'flex w-full items-start gap-3 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent',
+          className
+        )}
+      >
+        <span className="w-[6.5rem] shrink-0 pt-0.5 text-xs tabular-nums text-muted-foreground">
+          {start ? (end ? `${start} – ${end}` : start) : null}
+        </span>
+        <span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', palette.dot)} />
+        <span className="min-w-0 flex-1">
+          <span className={cn('block truncate font-medium', done && 'line-through opacity-70')}>
+            {event.title}
+          </span>
+          {(event.vehicleLabel || event.customerName) && (
+            <span className="block truncate text-xs text-muted-foreground">
+              {[event.vehicleLabel, event.customerName].filter(Boolean).join(' · ')}
+            </span>
+          )}
+        </span>
+      </button>
+    )
+  }
+
+  return <EventContextMenu event={event}>{button}</EventContextMenu>
+}

+ 121 - 0
src/features/calendar/Components/EventContextMenu.tsx

@@ -0,0 +1,121 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { Check, ExternalLink, Trash2, Undo2 } from 'lucide-react'
+import {
+  ContextMenu,
+  ContextMenuContent,
+  ContextMenuItem,
+  ContextMenuLabel,
+  ContextMenuSeparator,
+  ContextMenuTrigger,
+} from '@/components/ui/context-menu'
+import { useConfirm } from '@/components/confirm-dialog'
+import { deleteReminder, toggleReminder } from '@/features/vehicles/Actions/reminderActions'
+import { deleteScheduledMessage } from '@/features/scheduled-messages/Actions/scheduledMessageActions'
+import { getEventLink } from './calendar-utils'
+import { useEventPeek } from './EventPeek'
+import type { CalendarEvent } from '../Actions/calendarActions'
+
+/**
+ * Right-click on an event: open it, and for the things the calendar owns
+ * outright, act on them here. A reminder can be ticked off or deleted; a
+ * scheduled message can be deleted before it goes out. Work orders and
+ * quotes carry money and paper, so they are only opened from here.
+ */
+export function EventContextMenu({
+  event,
+  children,
+}: {
+  event: CalendarEvent
+  children: ReactNode
+}) {
+  const t = useTranslations('calendar.eventMenu')
+  const router = useRouter()
+  const confirm = useConfirm()
+  const { refresh } = useEventPeek()
+
+  const open = () => {
+    if (event.type === 'external') {
+      if (event.externalUrl) window.open(event.externalUrl, '_blank', 'noopener')
+      return
+    }
+    router.push(getEventLink(event))
+  }
+
+  const run = async (
+    action: () => Promise<{ success: boolean; error?: string }>,
+    doneMessage: string
+  ) => {
+    const result = await action()
+    if (result.success) {
+      toast.success(doneMessage)
+      refresh()
+    } else {
+      toast.error(result.error || t('failed'))
+    }
+  }
+
+  const removeReminder = async () => {
+    const ok = await confirm({
+      description: t('deleteReminderConfirm', { title: event.title }),
+      destructive: true,
+    })
+    if (ok) await run(() => deleteReminder(event.id), t('deleted'))
+  }
+
+  const removeMessage = async () => {
+    const ok = await confirm({
+      description: t('deleteMessageConfirm', { title: event.title }),
+      destructive: true,
+    })
+    if (ok) await run(() => deleteScheduledMessage(event.id), t('deleted'))
+  }
+
+  const reminderDone = event.type === 'reminder' && event.status === 'completed'
+
+  return (
+    <ContextMenu>
+      <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
+      <ContextMenuContent className="min-w-52">
+        <ContextMenuLabel className="truncate text-xs font-normal text-muted-foreground">
+          {event.title}
+        </ContextMenuLabel>
+        <ContextMenuSeparator />
+        <ContextMenuItem onClick={open}>
+          <ExternalLink className="mr-2 h-4 w-4" />
+          {t('open')}
+        </ContextMenuItem>
+        {event.type === 'reminder' && (
+          <>
+            <ContextMenuItem onClick={() => run(() => toggleReminder(event.id), t('updated'))}>
+              {reminderDone ? (
+                <Undo2 className="mr-2 h-4 w-4" />
+              ) : (
+                <Check className="mr-2 h-4 w-4" />
+              )}
+              {reminderDone ? t('markNotDone') : t('markDone')}
+            </ContextMenuItem>
+            <ContextMenuSeparator />
+            <ContextMenuItem variant="destructive" onClick={removeReminder}>
+              <Trash2 className="mr-2 h-4 w-4" />
+              {t('deleteReminder')}
+            </ContextMenuItem>
+          </>
+        )}
+        {event.type === 'message' && (
+          <>
+            <ContextMenuSeparator />
+            <ContextMenuItem variant="destructive" onClick={removeMessage}>
+              <Trash2 className="mr-2 h-4 w-4" />
+              {t('deleteMessage')}
+            </ContextMenuItem>
+          </>
+        )}
+      </ContextMenuContent>
+    </ContextMenu>
+  )
+}

+ 273 - 0
src/features/calendar/Components/EventPeek.tsx

@@ -0,0 +1,273 @@
+'use client'
+
+import Link from 'next/link'
+import {
+  createContext,
+  useCallback,
+  useContext,
+  useMemo,
+  useRef,
+  useState,
+  type ReactNode,
+} from 'react'
+import { useTranslations } from 'next-intl'
+import { useDayFormatter } from './useDayFormatter'
+import {
+  ArrowUpRight,
+  Bell,
+  CalendarClock,
+  Car,
+  ExternalLink,
+  FileText,
+  Hash,
+  Mail,
+  MessageSquare,
+  Send,
+  User,
+  Wrench,
+  X,
+} from 'lucide-react'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
+import { useDateSettings } from '@/components/date-settings-context'
+import { useFormatCurrency } from '@/components/currency-settings-context'
+import { formatClock } from '@/features/workboard/utils/clock'
+import { timeToMinutes } from '../Lib/calendar-range'
+import { eventPalette, getEventLink, isEventDone } from './calendar-utils'
+import { ExternalEventDialog } from './ExternalEventDialog'
+import type { CalendarEvent } from '../Actions/calendarActions'
+
+/**
+ * One floating card for the whole calendar, anchored to whichever chip was
+ * clicked, the way a desktop calendar peeks at an event before opening it.
+ * A single popover instead of one per chip: a busy month can hold hundreds
+ * of chips, and only one card is ever open.
+ */
+interface PeekState {
+  event: CalendarEvent
+  anchor: HTMLElement
+}
+
+interface PeekApi {
+  openPeek: (event: CalendarEvent, anchor: HTMLElement) => void
+  closePeek: () => void
+  /** Reloads the events after something was deleted or ticked off in place. */
+  refresh: () => void
+}
+
+const PeekContext = createContext<PeekApi>({
+  openPeek: () => undefined,
+  closePeek: () => undefined,
+  refresh: () => undefined,
+})
+
+export function useEventPeek() {
+  return useContext(PeekContext)
+}
+
+export function EventPeekProvider({
+  currencyCode,
+  onRefresh,
+  children,
+}: {
+  currencyCode: string
+  onRefresh?: () => void
+  children: ReactNode
+}) {
+  const [peek, setPeek] = useState<PeekState | null>(null)
+  const [external, setExternal] = useState<CalendarEvent | null>(null)
+  // Radix measures the anchor through a ref, so the clicked element is handed
+  // over as one; a new object each time so the popper re-measures.
+  const anchorRef = useRef<HTMLElement | null>(null)
+  anchorRef.current = peek?.anchor ?? null
+
+  const openPeek = useCallback((event: CalendarEvent, anchor: HTMLElement) => {
+    if (event.type === 'external') {
+      setPeek(null)
+      setExternal(event)
+      return
+    }
+    setPeek((current) =>
+      current && current.event === event && current.anchor === anchor ? null : { event, anchor }
+    )
+  }, [])
+  const closePeek = useCallback(() => setPeek(null), [])
+  const refresh = useCallback(() => {
+    setPeek(null)
+    onRefresh?.()
+  }, [onRefresh])
+  const api = useMemo(() => ({ openPeek, closePeek, refresh }), [openPeek, closePeek, refresh])
+
+  return (
+    <PeekContext.Provider value={api}>
+      {children}
+      <Popover open={peek !== null} onOpenChange={(open) => !open && setPeek(null)}>
+        <PopoverAnchor virtualRef={anchorRef as React.RefObject<HTMLElement>} />
+        <PopoverContent
+          side="right"
+          align="start"
+          collisionPadding={12}
+          className="w-[22rem] max-w-[calc(100vw-1.5rem)] p-0 shadow-xl"
+          onOpenAutoFocus={(e) => e.preventDefault()}
+        >
+          {peek && <PeekCard event={peek.event} currencyCode={currencyCode} onClose={closePeek} />}
+        </PopoverContent>
+      </Popover>
+      <ExternalEventDialog event={external} onClose={() => setExternal(null)} />
+    </PeekContext.Provider>
+  )
+}
+
+function typeIcon(event: CalendarEvent) {
+  switch (event.type) {
+    case 'service':
+      return Wrench
+    case 'reminder':
+      return Bell
+    case 'quote':
+      return FileText
+    case 'message':
+      return event.channel === 'email' ? Mail : event.channel === 'sms' ? MessageSquare : Send
+    default:
+      return CalendarClock
+  }
+}
+
+function statusKey(event: CalendarEvent): string {
+  if (event.type === 'service') {
+    switch (event.status) {
+      case 'completed':
+        return 'completed'
+      case 'in_progress':
+      case 'in-progress':
+        return 'inProgress'
+      case 'waiting-parts':
+        return 'waitingParts'
+      case 'scheduled':
+        return 'scheduled'
+      default:
+        return 'pending'
+    }
+  }
+  if (event.type === 'message') {
+    return event.status === 'sent' ? 'sent' : event.status === 'failed' ? 'failed' : 'queued'
+  }
+  if (event.type === 'quote') {
+    return event.status === 'sent' ? 'sent' : event.status === 'approved' ? 'approved' : 'draft'
+  }
+  if (event.type === 'external') return 'busy'
+  return event.status === 'completed' ? 'done' : event.status === 'overdue' ? 'overdue' : 'upcoming'
+}
+
+function PeekCard({
+  event,
+  currencyCode,
+  onClose,
+}: {
+  event: CalendarEvent
+  currencyCode: string
+  onClose: () => void
+}) {
+  const t = useTranslations('calendar')
+  const format = useDayFormatter()
+  const { timeFormat } = useDateSettings()
+  const formatCurrency = useFormatCurrency()
+  const palette = eventPalette(event)
+  const Icon = typeIcon(event)
+  const day = new Date(`${event.date}T12:00:00`)
+  const when = event.time
+    ? event.endTime
+      ? `${formatClock(timeToMinutes(event.time), timeFormat)} – ${formatClock(timeToMinutes(event.endTime), timeFormat)}`
+      : formatClock(timeToMinutes(event.time), timeFormat)
+    : t('events.external.allDay')
+  const done = isEventDone(event)
+
+  return (
+    <div className="overflow-hidden rounded-md">
+      <div className={`h-1.5 w-full ${palette.rule}`} />
+      <div className="p-4">
+        <div className="flex items-start gap-3">
+          <div
+            className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md ${palette.chip}`}
+          >
+            <Icon className="h-4 w-4" />
+          </div>
+          <div className="min-w-0 flex-1">
+            <p
+              className={`break-words text-base font-semibold leading-snug ${done ? 'text-muted-foreground line-through' : ''}`}
+            >
+              {event.title}
+            </p>
+            <p className="mt-0.5 text-sm text-muted-foreground">
+              {format.dateTime(day, { weekday: 'long', day: 'numeric', month: 'long' })}
+              <span className="mx-1.5 text-border">·</span>
+              {when}
+            </p>
+          </div>
+          <Button
+            variant="ghost"
+            size="icon"
+            className="-mr-2 -mt-2 h-8 w-8 shrink-0"
+            onClick={onClose}
+            aria-label={t('peek.close')}
+          >
+            <X className="h-4 w-4" />
+          </Button>
+        </div>
+
+        <div className="mt-3 flex flex-wrap items-center gap-1.5">
+          <Badge variant="secondary" className={`text-[10px] ${palette.chip}`}>
+            {t(`events.type.${event.type}`)}
+          </Badge>
+          <Badge variant="outline" className="text-[10px]">
+            {t(`events.status.${statusKey(event)}`)}
+          </Badge>
+        </div>
+
+        <dl className="mt-3 space-y-1.5 text-sm">
+          {event.vehicleLabel && (
+            <div className="flex items-center gap-2">
+              <Car className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+              <dd className="truncate">{event.vehicleLabel}</dd>
+            </div>
+          )}
+          {event.customerName && (
+            <div className="flex items-center gap-2">
+              <User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+              <dd className="truncate">{event.customerName}</dd>
+            </div>
+          )}
+          {(event.invoiceNumber || event.amount != null) && (
+            <div className="flex items-center gap-2">
+              <Hash className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+              <dd className="flex flex-1 items-center gap-2 truncate">
+                {event.invoiceNumber && (
+                  <span className="text-muted-foreground">{event.invoiceNumber}</span>
+                )}
+                {event.amount != null && (
+                  <span className="ml-auto font-medium tabular-nums">
+                    {formatCurrency(event.amount, currencyCode)}
+                  </span>
+                )}
+              </dd>
+            </div>
+          )}
+        </dl>
+
+        <div className="mt-4 flex items-center justify-end gap-2">
+          <Button asChild size="sm">
+            <Link href={getEventLink(event)}>
+              {t(`peek.open.${event.type}`)}
+              {event.type === 'external' ? (
+                <ExternalLink className="ml-1.5 h-3.5 w-3.5" />
+              ) : (
+                <ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
+              )}
+            </Link>
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 73 - 0
src/features/calendar/Components/ExternalEventDialog.tsx

@@ -0,0 +1,73 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { useDayFormatter } from './useDayFormatter'
+import { CalendarClock, ExternalLink } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import type { CalendarEvent } from '../Actions/calendarActions'
+
+/**
+ * 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.
+ */
+export function ExternalEventDialog({
+  event,
+  onClose,
+}: {
+  event: CalendarEvent | null
+  onClose: () => void
+}) {
+  const t = useTranslations('calendar.events.external')
+  const format = useDayFormatter()
+  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>
+  )
+}

+ 114 - 0
src/features/calendar/Components/MonthView.tsx

@@ -0,0 +1,114 @@
+'use client'
+
+import { useMemo } from 'react'
+import { useDayFormatter } from './useDayFormatter'
+import { cn } from '@/lib/utils'
+import { isoWeekNumber, isWeekend, toLocalDateStr } from '../Lib/calendar-range'
+import { CalendarDayCell } from './CalendarDayCell'
+import type { DayActions } from './DayContextMenu'
+import type { CalendarEvent } from '../Actions/calendarActions'
+
+interface MonthViewProps {
+  /** Every day of the grid, already padded to whole weeks. */
+  days: Date[]
+  month: number
+  eventsByDate: Map<string, CalendarEvent[]>
+  todayStr: string
+  selectedDateStr: string
+  showWeekends: boolean
+  showWeekNumbers: boolean
+  actions: DayActions
+  onSelectDate: (date: Date) => void
+  onOpenDay: (date: Date) => void
+}
+
+/**
+ * The month as a wall planner: week rows share the height evenly, so the
+ * grid always fills the screen and never scrolls, and each cell folds what
+ * does not fit into a "+N more".
+ */
+export function MonthView({
+  days,
+  month,
+  eventsByDate,
+  todayStr,
+  selectedDateStr,
+  showWeekends,
+  showWeekNumbers,
+  actions,
+  onSelectDate,
+  onOpenDay,
+}: MonthViewProps) {
+  const format = useDayFormatter()
+  const visibleDays = useMemo(
+    () => (showWeekends ? days : days.filter((d) => !isWeekend(d))),
+    [days, showWeekends]
+  )
+  const columns = showWeekends ? 7 : 5
+  const rows = Math.max(1, Math.round(visibleDays.length / columns))
+  const weekdayLabels = visibleDays
+    .slice(0, columns)
+    .map((d) => format.dateTime(d, { weekday: 'short' }))
+  const weekStarts = days.filter((_, i) => i % 7 === 0)
+
+  const gridTemplate = `${showWeekNumbers ? '2rem ' : ''}repeat(${columns}, minmax(0, 1fr))`
+
+  return (
+    <div className="flex min-h-0 flex-1 flex-col">
+      <div
+        className="grid shrink-0 border-b border-border/70"
+        style={{ gridTemplateColumns: gridTemplate }}
+      >
+        {showWeekNumbers && <div className="border-r border-border/70" />}
+        {weekdayLabels.map((label) => (
+          <div
+            key={label}
+            className="border-r border-border/70 py-1.5 text-center text-[11px] font-medium uppercase tracking-wide text-muted-foreground last:border-r-0"
+          >
+            {label}
+          </div>
+        ))}
+      </div>
+      <div
+        role="grid"
+        className="grid min-h-0 flex-1 border-l border-border/70"
+        style={{
+          gridTemplateColumns: gridTemplate,
+          gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
+        }}
+      >
+        {visibleDays.map((date, index) => {
+          const dateStr = toLocalDateStr(date)
+          const cell = (
+            <CalendarDayCell
+              key={dateStr}
+              date={date}
+              dateStr={dateStr}
+              events={eventsByDate.get(dateStr) ?? []}
+              isCurrentMonth={date.getMonth() === month}
+              isToday={dateStr === todayStr}
+              isSelected={dateStr === selectedDateStr}
+              isWeekend={isWeekend(date)}
+              actions={actions}
+              onSelect={() => onSelectDate(date)}
+              onOpenDay={() => onOpenDay(date)}
+            />
+          )
+          if (!showWeekNumbers || index % columns !== 0) return cell
+          const weekStart = weekStarts[Math.floor(index / columns)] ?? date
+          return [
+            <div
+              key={`w-${dateStr}`}
+              className={cn(
+                'flex items-start justify-center border-b border-r border-border/70 bg-muted/20 pt-2 text-[11px] tabular-nums text-muted-foreground'
+              )}
+            >
+              {isoWeekNumber(weekStart)}
+            </div>,
+            cell,
+          ]
+        })}
+      </div>
+    </div>
+  )
+}

+ 79 - 0
src/features/calendar/Components/ScheduleView.tsx

@@ -0,0 +1,79 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { useDayFormatter } from './useDayFormatter'
+import { CalendarOff } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { toLocalDateStr } from '../Lib/calendar-range'
+import { EventChip } from './EventChip'
+import { eventKey } from './calendar-utils'
+import type { CalendarEvent } from '../Actions/calendarActions'
+
+interface ScheduleViewProps {
+  days: Date[]
+  eventsByDate: Map<string, CalendarEvent[]>
+  todayStr: string
+  onSelectDate: (date: Date) => void
+}
+
+/**
+ * The agenda: every day in the range that has something on it, as a list.
+ * Reads top to bottom like a schedule printout, which is what a service
+ * desk wants when planning the coming weeks.
+ */
+export function ScheduleView({ days, eventsByDate, todayStr, onSelectDate }: ScheduleViewProps) {
+  const t = useTranslations('calendar')
+  const format = useDayFormatter()
+  const busyDays = days.filter((d) => (eventsByDate.get(toLocalDateStr(d))?.length ?? 0) > 0)
+
+  if (busyDays.length === 0) {
+    return (
+      <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
+        <CalendarOff className="h-8 w-8 text-muted-foreground/60" />
+        <p className="text-sm font-medium">{t('schedule.empty')}</p>
+        <p className="text-xs text-muted-foreground">{t('schedule.emptyHint')}</p>
+      </div>
+    )
+  }
+
+  return (
+    <div className="min-h-0 flex-1 overflow-y-auto">
+      <div className="mx-auto max-w-4xl divide-y divide-border/70">
+        {busyDays.map((date) => {
+          const dateStr = toLocalDateStr(date)
+          const events = eventsByDate.get(dateStr) ?? []
+          const isToday = dateStr === todayStr
+          return (
+            <section key={dateStr} className="flex gap-4 px-4 py-3">
+              <button
+                type="button"
+                onClick={() => onSelectDate(date)}
+                className="flex w-24 shrink-0 items-start gap-2 rounded-md text-left hover:bg-accent"
+              >
+                <span
+                  className={cn(
+                    'flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-lg font-semibold tabular-nums',
+                    isToday ? 'bg-primary text-primary-foreground' : 'text-foreground'
+                  )}
+                >
+                  {date.getDate()}
+                </span>
+                <span className="flex flex-col pt-0.5 text-[11px] uppercase leading-tight tracking-wide text-muted-foreground">
+                  <span className={cn(isToday && 'text-primary')}>
+                    {format.dateTime(date, { weekday: 'short' })}
+                  </span>
+                  <span>{format.dateTime(date, { month: 'short' })}</span>
+                </span>
+              </button>
+              <div className="flex min-w-0 flex-1 flex-col gap-0.5">
+                {events.map((event) => (
+                  <EventChip key={eventKey(event)} event={event} variant="row" />
+                ))}
+              </div>
+            </section>
+          )
+        })}
+      </div>
+    </div>
+  )
+}

+ 482 - 0
src/features/calendar/Components/TimeGridView.tsx

@@ -0,0 +1,482 @@
+'use client'
+
+import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
+import { useTranslations } from 'next-intl'
+import { Bell, FileText, Send, Wrench } from 'lucide-react'
+import { useDayFormatter } from './useDayFormatter'
+import { useDateSettings } from '@/components/date-settings-context'
+import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { formatClock } from '@/features/workboard/utils/clock'
+import { cn } from '@/lib/utils'
+import {
+  eventSpan,
+  isWeekend,
+  layoutTimedEvents,
+  minutesToTime,
+  timeToMinutes,
+  toLocalDateStr,
+} from '../Lib/calendar-range'
+import { DayContextMenu, type DayActions } from './DayContextMenu'
+import { EventChip } from './EventChip'
+import { eventKey } from './calendar-utils'
+import type { CalendarEvent } from '../Actions/calendarActions'
+
+/** Pixels per hour on the grid; 24 of them make the scrollable day. */
+export const HOUR_PX = 60
+const GUTTER = '3.5rem'
+/** All-day chips a column shows before folding the rest into a popover. */
+const MAX_ALL_DAY = 3
+/** The hour the grid scrolls to when the day has nothing earlier. */
+const DEFAULT_SCROLL_HOUR = 7
+/** Minutes the placeholder covers while the create menu is open. */
+const SLOT_MINUTES = 60
+/** A click on the grid rounds to the nearest of these. */
+const SNAP_MINUTES = 30
+const HOURS = Array.from({ length: 24 }, (_, h) => h)
+
+/** An empty slot somebody clicked, waiting to become something. */
+interface Slot {
+  date: Date
+  dateStr: string
+  /** HH:MM, snapped to the quarter hour. */
+  time: string
+  /** Where the click landed, so the menu opens right there. */
+  x: number
+  y: number
+}
+
+interface TimeGridViewProps {
+  days: Date[]
+  eventsByDate: Map<string, CalendarEvent[]>
+  todayStr: string
+  selectedDateStr: string
+  showWeekends: boolean
+  actions: DayActions
+  onSelectDate: (date: Date) => void
+  onOpenDay: (date: Date) => void
+}
+
+/**
+ * Day, four-day and week views share this: an all-day strip on top and a
+ * 24-hour grid below, with timed events as solid blocks and double bookings
+ * drawn side by side. A red line tracks the current time on today's column.
+ */
+export function TimeGridView({
+  days,
+  eventsByDate,
+  todayStr,
+  selectedDateStr,
+  showWeekends,
+  actions,
+  onSelectDate,
+  onOpenDay,
+}: TimeGridViewProps) {
+  const t = useTranslations('calendar')
+  const format = useDayFormatter()
+  const { timeFormat } = useDateSettings()
+  const scrollRef = useRef<HTMLDivElement>(null)
+  const [slot, setSlot] = useState<Slot | null>(null)
+  // Radix measures a virtual anchor through getBoundingClientRect, so the
+  // click point is wrapped as a zero-size box. Memoised per slot: Radix
+  // stores the anchor in state on every render, and a fresh object each
+  // time would re-render without end.
+  const slotAnchor = useMemo(() => {
+    const x = slot?.x ?? 0
+    const y = slot?.y ?? 0
+    return { current: { getBoundingClientRect: () => new DOMRect(x, y, 0, 0) } }
+  }, [slot])
+  const runSlotAction = (action: (dateStr: string, time?: string) => void) => {
+    if (!slot) return
+    const { dateStr, time } = slot
+    setSlot(null)
+    action(dateStr, time)
+  }
+  const visibleDays = useMemo(
+    () => (showWeekends || days.length < 7 ? days : days.filter((d) => !isWeekend(d))),
+    [days, showWeekends]
+  )
+  const columnsTemplate = `${GUTTER} repeat(${visibleDays.length}, minmax(0, 1fr))`
+
+  const [nowMins, setNowMins] = useState<number | null>(null)
+  useEffect(() => {
+    const tick = () => {
+      const n = new Date()
+      setNowMins(n.getHours() * 60 + n.getMinutes())
+    }
+    tick()
+    const id = window.setInterval(tick, 60_000)
+    return () => window.clearInterval(id)
+  }, [])
+
+  // Land the reader on the working day, or an hour before the first thing
+  // scheduled if that is earlier. Only on mount: a re-scroll on every
+  // navigation would fight the person scrolling.
+  useEffect(() => {
+    const el = scrollRef.current
+    if (!el) return
+    let firstHour = DEFAULT_SCROLL_HOUR
+    for (const d of visibleDays) {
+      for (const e of eventsByDate.get(toLocalDateStr(d)) ?? []) {
+        const span = eventSpan(e)
+        if (span) firstHour = Math.min(firstHour, Math.floor(span.startMins / 60) - 1)
+      }
+    }
+    el.scrollTop = Math.max(0, firstHour) * HOUR_PX
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  return (
+    <div className="flex min-h-0 flex-1 flex-col">
+      {/* Day headers */}
+      <div
+        className="grid shrink-0 border-b border-border/70"
+        style={{ gridTemplateColumns: columnsTemplate }}
+      >
+        <div className="border-r border-border/70" />
+        {visibleDays.map((date) => {
+          const dateStr = toLocalDateStr(date)
+          const isToday = dateStr === todayStr
+          const isSelected = dateStr === selectedDateStr
+          return (
+            <button
+              key={dateStr}
+              type="button"
+              onClick={() => (visibleDays.length === 1 ? undefined : onOpenDay(date))}
+              className={cn(
+                'group flex flex-col items-center gap-0.5 border-r border-border/70 py-2 transition-colors last:border-r-0',
+                visibleDays.length > 1 && 'hover:bg-accent/40',
+                isSelected && !isToday && 'bg-primary/5'
+              )}
+            >
+              <span
+                className={cn(
+                  'text-[11px] font-medium uppercase tracking-wide',
+                  isToday ? 'text-primary' : 'text-muted-foreground'
+                )}
+              >
+                {format.dateTime(date, { weekday: 'short' })}
+              </span>
+              <span
+                className={cn(
+                  'flex h-9 w-9 items-center justify-center rounded-full text-xl font-medium tabular-nums transition-colors',
+                  isToday
+                    ? 'bg-primary text-primary-foreground'
+                    : 'text-foreground group-hover:bg-accent'
+                )}
+              >
+                {date.getDate()}
+              </span>
+            </button>
+          )
+        })}
+      </div>
+
+      {/* All-day strip */}
+      <div
+        className="grid shrink-0 border-b border-border/70"
+        style={{ gridTemplateColumns: columnsTemplate }}
+      >
+        <div className="flex items-start justify-end border-r border-border/70 px-1.5 pt-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
+          {t('allDay')}
+        </div>
+        {visibleDays.map((date) => {
+          const dateStr = toLocalDateStr(date)
+          const allDay = (eventsByDate.get(dateStr) ?? []).filter((e) => !e.time)
+          const shown = allDay.slice(0, MAX_ALL_DAY)
+          const hidden = allDay.length - shown.length
+          return (
+            <DayContextMenu
+              key={dateStr}
+              date={date}
+              dateStr={dateStr}
+              events={eventsByDate.get(dateStr) ?? []}
+              actions={actions}
+              onOpen={() => onSelectDate(date)}
+            >
+              <div
+                className="flex min-h-8 flex-col gap-0.5 border-r border-border/70 p-1 last:border-r-0"
+                onClick={() => onSelectDate(date)}
+              >
+                {shown.map((event) => (
+                  <EventChip key={eventKey(event)} event={event} variant="chip" />
+                ))}
+                {hidden > 0 && (
+                  <Popover>
+                    <PopoverTrigger asChild>
+                      <button
+                        type="button"
+                        onClick={(e) => e.stopPropagation()}
+                        className="h-5 rounded px-1.5 text-left text-[11px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
+                      >
+                        {t('more', { count: hidden })}
+                      </button>
+                    </PopoverTrigger>
+                    <PopoverContent
+                      align="start"
+                      className="w-72 p-2"
+                      onOpenAutoFocus={(e) => e.preventDefault()}
+                    >
+                      <div className="flex max-h-80 flex-col gap-0.5 overflow-y-auto">
+                        {allDay.map((event) => (
+                          <EventChip key={eventKey(event)} event={event} variant="chip" />
+                        ))}
+                      </div>
+                    </PopoverContent>
+                  </Popover>
+                )}
+              </div>
+            </DayContextMenu>
+          )
+        })}
+      </div>
+
+      {/* Hour grid */}
+      <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
+        <div
+          className="relative grid"
+          style={{ gridTemplateColumns: columnsTemplate, height: 24 * HOUR_PX }}
+        >
+          <div className="relative border-r border-border/70">
+            {HOURS.map((h) =>
+              h === 0 ? null : (
+                <span
+                  key={h}
+                  className="absolute right-1.5 -translate-y-1/2 text-[10px] tabular-nums text-muted-foreground"
+                  style={{ top: h * HOUR_PX }}
+                >
+                  {formatClock(h * 60, timeFormat)}
+                </span>
+              )
+            )}
+          </div>
+          {visibleDays.map((date) => (
+            <DayColumn
+              key={toLocalDateStr(date)}
+              date={date}
+              events={eventsByDate.get(toLocalDateStr(date)) ?? []}
+              isToday={toLocalDateStr(date) === todayStr}
+              nowMins={nowMins}
+              actions={actions}
+              placeholderMins={
+                slot && slot.dateStr === toLocalDateStr(date) ? timeToMinutes(slot.time) : null
+              }
+              onSelect={() => onSelectDate(date)}
+              onSlotClick={(time, x, y) => {
+                onSelectDate(date)
+                setSlot({ date, dateStr: toLocalDateStr(date), time, x, y })
+              }}
+            />
+          ))}
+        </div>
+      </div>
+
+      {/* What an empty slot can become, opened where the click landed. */}
+      <Popover open={slot !== null} onOpenChange={(open) => !open && setSlot(null)}>
+        <PopoverAnchor virtualRef={slotAnchor} />
+        <PopoverContent
+          side="right"
+          align="start"
+          collisionPadding={12}
+          className="w-64 p-1.5"
+          onOpenAutoFocus={(e) => e.preventDefault()}
+        >
+          {slot && (
+            <>
+              <p className="px-2 pb-1.5 pt-1 text-xs text-muted-foreground">
+                {format.dateTime(slot.date, { weekday: 'long', day: 'numeric', month: 'long' })}
+                {' · '}
+                {formatClock(timeToMinutes(slot.time), timeFormat)}
+              </p>
+              <SlotMenuItem
+                icon={Wrench}
+                label={t('contextMenu.newWorkOrderAt', {
+                  time: formatClock(timeToMinutes(slot.time), timeFormat),
+                })}
+                onClick={() => runSlotAction(actions.onNewWorkOrder)}
+              />
+              <SlotMenuItem
+                icon={Bell}
+                label={t('contextMenu.newReminder')}
+                onClick={() => runSlotAction(actions.onNewReminder)}
+              />
+              <SlotMenuItem
+                icon={FileText}
+                label={t('contextMenu.newQuote')}
+                onClick={() => runSlotAction(actions.onNewQuote)}
+              />
+              <SlotMenuItem
+                icon={Send}
+                label={t('contextMenu.scheduleMessage')}
+                onClick={() => runSlotAction(actions.onScheduleMessage)}
+              />
+            </>
+          )}
+        </PopoverContent>
+      </Popover>
+    </div>
+  )
+}
+
+function SlotMenuItem({
+  icon: Icon,
+  label,
+  onClick,
+}: {
+  icon: typeof Wrench
+  label: string
+  onClick: () => void
+}) {
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
+    >
+      <Icon className="h-4 w-4 text-muted-foreground" />
+      {label}
+    </button>
+  )
+}
+
+function minutesAtPointer(e: MouseEvent<HTMLElement>): number {
+  const rect = e.currentTarget.getBoundingClientRect()
+  const ratio = (e.clientY - rect.top) / Math.max(rect.height, 1)
+  // Snap to the half hour: close enough to aim at, and the dialog that opens
+  // is where a precise time gets typed if it matters.
+  return Math.max(
+    0,
+    Math.min(24 * 60 - SNAP_MINUTES, Math.round((ratio * 24 * 60) / SNAP_MINUTES) * SNAP_MINUTES)
+  )
+}
+
+function DayColumn({
+  date,
+  events,
+  isToday,
+  nowMins,
+  actions,
+  placeholderMins,
+  onSelect,
+  onSlotClick,
+}: {
+  date: Date
+  events: CalendarEvent[]
+  isToday: boolean
+  nowMins: number | null
+  actions: DayActions
+  /** Start of the slot being created, drawn as a ghost block. */
+  placeholderMins: number | null
+  onSelect: () => void
+  onSlotClick: (time: string, x: number, y: number) => void
+}) {
+  const { timeFormat } = useDateSettings()
+  const dateStr = toLocalDateStr(date)
+  const columnRef = useRef<HTMLDivElement>(null)
+  const [menuTime, setMenuTime] = useState<string | null>(null)
+
+  const positioned = useMemo(
+    () =>
+      layoutTimedEvents(
+        events
+          .map((event) => ({ item: event, span: eventSpan(event) }))
+          .filter((x): x is { item: CalendarEvent; span: NonNullable<typeof x.span> } =>
+            Boolean(x.span)
+          )
+      ),
+    [events]
+  )
+
+  return (
+    <DayContextMenu
+      date={date}
+      dateStr={dateStr}
+      events={events}
+      actions={actions}
+      time={menuTime}
+      onOpen={onSelect}
+      getAnchor={() => columnRef.current}
+    >
+      <div
+        ref={columnRef}
+        className={cn(
+          'relative border-r border-border/70 last:border-r-0',
+          isToday && 'bg-primary/[0.03]'
+        )}
+        onClick={(e) => {
+          // A click on a block opens its peek; a click on the grid itself
+          // asks what to put there.
+          if (e.target === e.currentTarget) {
+            onSlotClick(minutesToTime(minutesAtPointer(e)), e.clientX, e.clientY)
+          } else {
+            onSelect()
+          }
+        }}
+        onContextMenu={(e) => setMenuTime(minutesToTime(minutesAtPointer(e)))}
+        onDoubleClick={(e) => {
+          if (e.target !== e.currentTarget) return
+          actions.onNewWorkOrder(dateStr, minutesToTime(minutesAtPointer(e)))
+        }}
+      >
+        {placeholderMins !== null && (
+          <div
+            className="pointer-events-none absolute inset-x-0.5 z-20 flex items-start rounded-md border-2 border-dashed border-primary/70 bg-primary/10 px-1.5 py-1 text-xs font-medium text-primary"
+            style={{
+              top: (placeholderMins / 60) * HOUR_PX,
+              height: (Math.min(SLOT_MINUTES, 24 * 60 - placeholderMins) / 60) * HOUR_PX,
+            }}
+          >
+            {formatClock(placeholderMins, timeFormat)}
+          </div>
+        )}
+        {HOURS.map((h) => (
+          <div
+            key={h}
+            className="pointer-events-none absolute inset-x-0 border-t border-border/60"
+            style={{ top: h * HOUR_PX }}
+          >
+            <div
+              className="absolute inset-x-0 border-t border-dashed border-border/40"
+              style={{ top: HOUR_PX / 2 }}
+            />
+          </div>
+        ))}
+
+        {positioned.map(({ item, startMins, endMins, column, columns }) => {
+          const top = (startMins / 60) * HOUR_PX
+          const height = ((endMins - startMins) / 60) * HOUR_PX
+          // Overlapping events step in from the left and each runs to the
+          // right edge, stacked so the later one sits on top: the first
+          // keeps its title readable and every one stays wide enough to
+          // read, which equal columns cannot promise on a busy morning.
+          const step = columns > 1 ? Math.min(100 / columns, 35) : 0
+          const left = column * step
+          return (
+            <EventChip
+              key={eventKey(item)}
+              event={item}
+              variant="block"
+              compact={height < 34}
+              style={{
+                top,
+                height: Math.max(height - 2, 16),
+                left: `calc(${left}% + 2px)`,
+                width: `calc(${100 - left}% - 4px)`,
+                zIndex: 10 + column,
+              }}
+            />
+          )
+        })}
+
+        {isToday && nowMins !== null && (
+          <div
+            className="pointer-events-none absolute inset-x-0 z-30 flex items-center"
+            style={{ top: (nowMins / 60) * HOUR_PX }}
+          >
+            <span className="-ml-1.5 h-3 w-3 rounded-full bg-red-500 shadow" />
+            <span className="h-0.5 flex-1 bg-red-500" />
+          </div>
+        )}
+      </div>
+    </DayContextMenu>
+  )
+}

+ 107 - 0
src/features/calendar/Components/YearView.tsx

@@ -0,0 +1,107 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { useDayFormatter } from './useDayFormatter'
+import { useDateSettings } from '@/components/date-settings-context'
+import { cn } from '@/lib/utils'
+import { getMonthGridDays, isWeekend, toLocalDateStr } from '../Lib/calendar-range'
+import type { CalendarEvent } from '../Actions/calendarActions'
+
+interface YearViewProps {
+  year: number
+  eventsByDate: Map<string, CalendarEvent[]>
+  todayStr: string
+  selectedDateStr: string
+  onSelectDate: (date: Date) => void
+  onOpenDay: (date: Date) => void
+}
+
+/**
+ * Twelve small months at once. Each day carries a heat mark for how much is
+ * on it, so a glance shows the busy weeks of the year; a click selects the
+ * day and a double click opens it.
+ */
+export function YearView({
+  year,
+  eventsByDate,
+  todayStr,
+  selectedDateStr,
+  onSelectDate,
+  onOpenDay,
+}: YearViewProps) {
+  const t = useTranslations('calendar')
+  const format = useDayFormatter()
+  const { weekStartDay } = useDateSettings()
+  const weekdayInitials = Array.from({ length: 7 }, (_, i) => {
+    // Any week will do for the initials; the 4th of Jan 2026 is a Sunday.
+    const d = new Date(2026, 0, 4 + ((weekStartDay + i) % 7))
+    return format.dateTime(d, { weekday: 'narrow' })
+  })
+
+  return (
+    <div className="min-h-0 flex-1 overflow-y-auto p-4">
+      <div className="grid gap-x-6 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
+        {Array.from({ length: 12 }, (_, month) => {
+          const days = getMonthGridDays(year, month, weekStartDay)
+          return (
+            <section key={month} className="min-w-0">
+              <button
+                type="button"
+                onClick={() => onSelectDate(new Date(year, month, 1))}
+                className="mb-2 rounded px-1 text-sm font-semibold hover:bg-accent"
+              >
+                {format.dateTime(new Date(year, month, 1), { month: 'long' })}
+              </button>
+              <div className="grid grid-cols-7 gap-y-0.5 text-center">
+                {weekdayInitials.map((label, i) => (
+                  <span
+                    key={`${label}-${i}`}
+                    className="text-[10px] font-medium uppercase text-muted-foreground"
+                  >
+                    {label}
+                  </span>
+                ))}
+                {days.map((date) => {
+                  const dateStr = toLocalDateStr(date)
+                  const inMonth = date.getMonth() === month
+                  const count = inMonth ? (eventsByDate.get(dateStr)?.length ?? 0) : 0
+                  const isToday = dateStr === todayStr
+                  const isSelected = dateStr === selectedDateStr
+                  if (!inMonth) return <span key={dateStr} className="h-8" />
+                  return (
+                    <button
+                      key={dateStr}
+                      type="button"
+                      onClick={() => onSelectDate(date)}
+                      onDoubleClick={() => onOpenDay(date)}
+                      title={count > 0 ? t('dayTotal', { count }) : undefined}
+                      className={cn(
+                        'group relative mx-auto flex h-8 w-8 flex-col items-center justify-center rounded-full text-xs tabular-nums transition-colors',
+                        isToday
+                          ? 'bg-primary font-bold text-primary-foreground'
+                          : isSelected
+                            ? 'bg-primary/15 font-semibold text-foreground'
+                            : cn('hover:bg-accent', isWeekend(date) && 'text-muted-foreground')
+                      )}
+                    >
+                      <span className="leading-none">{date.getDate()}</span>
+                      {count > 0 && (
+                        <span
+                          className={cn(
+                            'absolute bottom-1 h-1 rounded-full',
+                            isToday ? 'bg-primary-foreground/80' : 'bg-primary',
+                            count >= 5 ? 'w-4' : count >= 3 ? 'w-3' : 'w-1.5'
+                          )}
+                        />
+                      )}
+                    </button>
+                  )
+                })}
+              </div>
+            </section>
+          )
+        })}
+      </div>
+    </div>
+  )
+}

+ 160 - 13
src/features/calendar/Components/calendar-utils.ts

@@ -1,26 +1,25 @@
-/** Format a Date as YYYY-MM-DD in local time (avoids UTC shift from toISOString) */
-export function toLocalDateStr(d: Date): string {
-  const y = d.getFullYear()
-  const m = String(d.getMonth() + 1).padStart(2, '0')
-  const day = String(d.getDate()).padStart(2, '0')
-  return `${y}-${m}-${day}`
-}
+import { serviceRecordHref } from '@/lib/service-record'
+import type { CalendarEvent } from '../Actions/calendarActions'
+
+export { toLocalDateStr } from '../Lib/calendar-range'
 
 /**
- * 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.
+ * Where an event points: the work order, the quote, the reminders list, the
+ * scheduled messages tab. 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: {
   id: string
-  type: 'service' | 'reminder' | 'quote' | 'message' | 'external'
+  type: CalendarEvent['type']
   vehicleId: string | null
 }): string {
   if (event.type === 'external') return '/settings/integrations'
   if (event.type === 'quote') return `/quotes/${event.id}`
   if (event.type === 'message') return '/messages?tab=scheduled'
-  if (!event.vehicleId) return event.type === 'reminder' ? '/reminders' : `/sales/${event.id}`
-  return `/vehicles/${event.vehicleId}`
+  if (event.type === 'reminder')
+    return event.vehicleId ? `/vehicles/${event.vehicleId}` : '/reminders'
+  return serviceRecordHref({ id: event.id, vehicleId: event.vehicleId })
 }
 
 export function formatDateHeader(d: Date): string {
@@ -31,3 +30,151 @@ export function formatDateHeader(d: Date): string {
     year: 'numeric',
   })
 }
+
+/** A stable React key: ids can repeat across types (and external days). */
+export function eventKey(event: Pick<CalendarEvent, 'id' | 'type'>): string {
+  return `${event.type}-${event.id}`
+}
+
+/**
+ * One colour family per event type, the way a desktop calendar gives each
+ * calendar its own hue, with the status pushing the shade: done turns green,
+ * late or failed turns red, waiting-for-parts turns orange. Every view draws
+ * from these same classes so a chip in the month, a block in the week and a
+ * row in the schedule all read as the same thing.
+ */
+export interface EventPalette {
+  /** Solid dot beside a timed event or in a year cell. */
+  dot: string
+  /** Soft tinted chip for all-day rows, month cells and lists. */
+  chip: string
+  /** Solid block on the time grid. */
+  block: string
+  /** Text colour for a plain-text chip. */
+  text: string
+  /** Left rule on a peek card or list row. */
+  rule: string
+}
+
+type Hue = 'blue' | 'amber' | 'violet' | 'teal' | 'slate' | 'emerald' | 'orange' | 'red'
+
+const PALETTES: Record<Hue, EventPalette> = {
+  blue: {
+    dot: 'bg-blue-500',
+    chip: 'bg-blue-500/15 text-blue-900 hover:bg-blue-500/25 dark:text-blue-200',
+    block: 'bg-blue-600 text-white border-blue-700/60 hover:bg-blue-700',
+    text: 'text-blue-800 dark:text-blue-300',
+    rule: 'bg-blue-500',
+  },
+  amber: {
+    dot: 'bg-amber-500',
+    chip: 'bg-amber-500/20 text-amber-950 hover:bg-amber-500/30 dark:text-amber-200',
+    block: 'bg-amber-500 text-amber-950 border-amber-600/60 hover:bg-amber-600',
+    text: 'text-amber-800 dark:text-amber-300',
+    rule: 'bg-amber-500',
+  },
+  violet: {
+    dot: 'bg-violet-500',
+    chip: 'bg-violet-500/15 text-violet-950 hover:bg-violet-500/25 dark:text-violet-200',
+    block: 'bg-violet-600 text-white border-violet-700/60 hover:bg-violet-700',
+    text: 'text-violet-800 dark:text-violet-300',
+    rule: 'bg-violet-500',
+  },
+  teal: {
+    dot: 'bg-teal-500',
+    chip: 'bg-teal-500/15 text-teal-950 hover:bg-teal-500/25 dark:text-teal-200',
+    block: 'bg-teal-600 text-white border-teal-700/60 hover:bg-teal-700',
+    text: 'text-teal-800 dark:text-teal-300',
+    rule: 'bg-teal-500',
+  },
+  slate: {
+    dot: 'bg-slate-400',
+    chip: 'bg-slate-500/15 text-slate-700 hover:bg-slate-500/25 dark:text-slate-300',
+    block: 'bg-slate-500 text-white border-slate-600/60 hover:bg-slate-600',
+    text: 'text-slate-600 dark:text-slate-400',
+    rule: 'bg-slate-400',
+  },
+  emerald: {
+    dot: 'bg-emerald-500',
+    chip: 'bg-emerald-500/15 text-emerald-950 hover:bg-emerald-500/25 dark:text-emerald-200',
+    block: 'bg-emerald-600 text-white border-emerald-700/60 hover:bg-emerald-700',
+    text: 'text-emerald-800 dark:text-emerald-300',
+    rule: 'bg-emerald-500',
+  },
+  orange: {
+    dot: 'bg-orange-500',
+    chip: 'bg-orange-500/15 text-orange-950 hover:bg-orange-500/25 dark:text-orange-200',
+    block: 'bg-orange-500 text-white border-orange-600/60 hover:bg-orange-600',
+    text: 'text-orange-800 dark:text-orange-300',
+    rule: 'bg-orange-500',
+  },
+  red: {
+    dot: 'bg-red-500',
+    chip: 'bg-red-500/15 text-red-950 hover:bg-red-500/25 dark:text-red-200',
+    block: 'bg-red-600 text-white border-red-700/60 hover:bg-red-700',
+    text: 'text-red-800 dark:text-red-300',
+    rule: 'bg-red-500',
+  },
+}
+
+/** The hue a type carries before status pushes it around; used by legends. */
+export const TYPE_HUE: Record<CalendarEvent['type'], Hue> = {
+  service: 'blue',
+  reminder: 'amber',
+  quote: 'violet',
+  message: 'teal',
+  external: 'slate',
+}
+
+export function typePalette(type: CalendarEvent['type']): EventPalette {
+  return PALETTES[TYPE_HUE[type]]
+}
+
+export function eventHue(event: Pick<CalendarEvent, 'type' | 'status'>): Hue {
+  switch (event.type) {
+    case 'service':
+      switch (event.status) {
+        case 'completed':
+          return 'emerald'
+        case 'waiting-parts':
+          return 'orange'
+        default:
+          return 'blue'
+      }
+    case 'reminder':
+      switch (event.status) {
+        case 'completed':
+          return 'emerald'
+        case 'overdue':
+          return 'red'
+        default:
+          return 'amber'
+      }
+    case 'quote':
+      return event.status === 'approved' ? 'emerald' : 'violet'
+    case 'message':
+      return event.status === 'failed' ? 'red' : 'teal'
+    default:
+      return 'slate'
+  }
+}
+
+export function eventPalette(event: Pick<CalendarEvent, 'type' | 'status'>): EventPalette {
+  return PALETTES[eventHue(event)]
+}
+
+/** Done things sit quietly: a completed job or reminder is dimmed and struck. */
+export function isEventDone(event: Pick<CalendarEvent, 'type' | 'status'>): boolean {
+  return (
+    (event.type === 'service' && event.status === 'completed') ||
+    (event.type === 'reminder' && event.status === 'completed') ||
+    (event.type === 'message' && event.status === 'sent')
+  )
+}
+
+/** All-day items come first, then by clock time, then by title. */
+export function compareEvents(a: CalendarEvent, b: CalendarEvent): number {
+  if (!a.time !== !b.time) return a.time ? 1 : -1
+  if (a.time && b.time && a.time !== b.time) return a.time < b.time ? -1 : 1
+  return a.title.localeCompare(b.title)
+}

+ 59 - 0
src/features/calendar/Components/useCalendarPreferences.ts

@@ -0,0 +1,59 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+
+/**
+ * How this person likes the calendar drawn. Kept in the browser: these are
+ * viewing habits, not workshop data, and a planner on the front desk PC and
+ * a phone in the pit may well want different ones.
+ */
+export interface CalendarPreferences {
+  showWeekends: boolean
+  showCompleted: boolean
+  showWeekNumbers: boolean
+  sidebarOpen: boolean
+}
+
+const STORAGE_KEY = 'torqvoice.calendar.preferences'
+
+const DEFAULTS: CalendarPreferences = {
+  showWeekends: true,
+  showCompleted: true,
+  showWeekNumbers: false,
+  sidebarOpen: true,
+}
+
+function read(): CalendarPreferences {
+  try {
+    const raw = window.localStorage.getItem(STORAGE_KEY)
+    if (!raw) return DEFAULTS
+    const parsed = JSON.parse(raw) as Partial<CalendarPreferences>
+    return { ...DEFAULTS, ...parsed }
+  } catch {
+    return DEFAULTS
+  }
+}
+
+export function useCalendarPreferences() {
+  // Defaults on the server and the first client paint, so hydration agrees;
+  // the stored values land right after.
+  const [preferences, setPreferences] = useState<CalendarPreferences>(DEFAULTS)
+
+  useEffect(() => {
+    setPreferences(read())
+  }, [])
+
+  const update = useCallback((patch: Partial<CalendarPreferences>) => {
+    setPreferences((current) => {
+      const next = { ...current, ...patch }
+      try {
+        window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
+      } catch {
+        // Private mode or a full store: the choice still applies for the session.
+      }
+      return next
+    })
+  }, [])
+
+  return { preferences, update }
+}

+ 24 - 0
src/features/calendar/Components/useDayFormatter.ts

@@ -0,0 +1,24 @@
+'use client'
+
+import { useMemo } from 'react'
+import { type DateTimeFormatOptions, useFormatter } from 'next-intl'
+
+/**
+ * next-intl formats in the zone the server configured (or its own), which
+ * is not the zone the calendar's dates are built in: every day here is a
+ * local-midnight Date of whatever runtime made it. Formatting such a date
+ * in UTC from an Oslo browser turns Monday into Sunday and "Sep 1" into
+ * "Aug 31". So the calendar formats in the runtime's own zone, on both
+ * sides of hydration: the server's dates in the server's zone, the
+ * browser's in the browser's, and each reads its own dates back correctly.
+ */
+export function useDayFormatter() {
+  const format = useFormatter()
+  return useMemo(() => {
+    const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
+    return {
+      dateTime: (date: Date, options: DateTimeFormatOptions) =>
+        format.dateTime(date, { ...options, timeZone }),
+    }
+  }, [format])
+}

+ 258 - 0
src/features/calendar/Lib/calendar-range.ts

@@ -0,0 +1,258 @@
+/**
+ * The calendar's date arithmetic, kept free of React and the database so the
+ * views can be tested as plain functions.
+ *
+ * Everything here works on local calendar days. The browser's clock is the
+ * workshop's clock for display purposes, the same assumption the work board
+ * makes; the server side speaks the workshop's configured zone when it turns
+ * instants into day keys (see calendarActions).
+ */
+
+export type CalendarView = 'day' | 'fourDays' | 'week' | 'month' | 'year' | 'schedule'
+
+export const CALENDAR_VIEWS: CalendarView[] = [
+  'day',
+  'week',
+  'month',
+  'year',
+  'schedule',
+  'fourDays',
+]
+
+export function isCalendarView(value: string | null | undefined): value is CalendarView {
+  return CALENDAR_VIEWS.includes(value as CalendarView)
+}
+
+/** Format a Date as YYYY-MM-DD in local time (avoids UTC shift from toISOString) */
+export function toLocalDateStr(d: Date): string {
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return `${y}-${m}-${day}`
+}
+
+/** YYYY-MM-DD to a local-midnight Date; anything else is null. */
+export function parseDateKey(value: string | null | undefined): Date | null {
+  if (!value) return null
+  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
+  if (!m) return null
+  const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
+  return Number.isNaN(d.getTime()) ? null : d
+}
+
+export function addDays(d: Date, n: number): Date {
+  return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
+}
+
+export function addMonths(d: Date, n: number): Date {
+  // Clamp the day so Jan 31 + 1 month lands on Feb 28/29, not Mar 3.
+  const first = new Date(d.getFullYear(), d.getMonth() + n, 1)
+  const lastDay = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate()
+  return new Date(first.getFullYear(), first.getMonth(), Math.min(d.getDate(), lastDay))
+}
+
+export function isSameDay(a: Date, b: Date): boolean {
+  return (
+    a.getFullYear() === b.getFullYear() &&
+    a.getMonth() === b.getMonth() &&
+    a.getDate() === b.getDate()
+  )
+}
+
+export function isWeekend(d: Date): boolean {
+  const w = d.getDay()
+  return w === 0 || w === 6
+}
+
+/** The first day of the week `d` sits in, honouring the workshop's week start. */
+export function startOfWeek(d: Date, weekStartDay: number): Date {
+  const offset = (d.getDay() - weekStartDay + 7) % 7
+  return addDays(new Date(d.getFullYear(), d.getMonth(), d.getDate()), -offset)
+}
+
+/** ISO 8601 week number, Monday-based regardless of the display setting. */
+export function isoWeekNumber(d: Date): number {
+  const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
+  const day = date.getUTCDay() || 7
+  date.setUTCDate(date.getUTCDate() + 4 - day)
+  const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
+  return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
+}
+
+/**
+ * Every day a month grid shows: leading days of the previous month so the
+ * first row starts on the week start, and trailing days so the last row is
+ * full. Between four and six rows.
+ */
+export function getMonthGridDays(year: number, month: number, weekStartDay: number): Date[] {
+  const first = new Date(year, month, 1)
+  const start = startOfWeek(first, weekStartDay)
+  const lastDay = new Date(year, month + 1, 0)
+  const days: Date[] = []
+  for (let d = start; d <= lastDay || days.length % 7 !== 0; d = addDays(d, 1)) {
+    days.push(d)
+  }
+  return days
+}
+
+export interface DateRange {
+  /** First day shown, inclusive. */
+  start: Date
+  /** Last day shown, inclusive. */
+  end: Date
+}
+
+/** The days a view draws around `date`. */
+export function visibleRange(view: CalendarView, date: Date, weekStartDay: number): DateRange {
+  switch (view) {
+    case 'day':
+      return { start: date, end: date }
+    case 'fourDays':
+      return { start: date, end: addDays(date, 3) }
+    case 'week': {
+      const start = startOfWeek(date, weekStartDay)
+      return { start, end: addDays(start, 6) }
+    }
+    case 'month':
+    case 'schedule': {
+      const days = getMonthGridDays(date.getFullYear(), date.getMonth(), weekStartDay)
+      return { start: days[0], end: days[days.length - 1] }
+    }
+    case 'year':
+      return {
+        start: new Date(date.getFullYear(), 0, 1),
+        end: new Date(date.getFullYear(), 11, 31),
+      }
+  }
+}
+
+/** Where the previous/next buttons take a view from `date`. */
+export function shiftDate(view: CalendarView, date: Date, direction: -1 | 1): Date {
+  switch (view) {
+    case 'day':
+      return addDays(date, direction)
+    case 'fourDays':
+      return addDays(date, 4 * direction)
+    case 'week':
+      return addDays(date, 7 * direction)
+    case 'month':
+    case 'schedule':
+      return addMonths(date, direction)
+    case 'year':
+      return new Date(date.getFullYear() + direction, date.getMonth(), Math.min(date.getDate(), 28))
+  }
+}
+
+/** Whether `inner` lies entirely inside `outer`, so a loaded window can serve it. */
+export function rangeCovers(outer: DateRange | null, inner: DateRange): boolean {
+  if (!outer) return false
+  return outer.start <= inner.start && outer.end >= inner.end
+}
+
+export function eachDay(range: DateRange): Date[] {
+  const days: Date[] = []
+  for (let d = range.start; d <= range.end; d = addDays(d, 1)) days.push(d)
+  return days
+}
+
+/** "HH:MM" to minutes from midnight; malformed input is treated as midnight. */
+export function timeToMinutes(time: string | null | undefined): number {
+  if (!time) return 0
+  const [h, m] = time.split(':').map(Number)
+  if (!Number.isFinite(h) || !Number.isFinite(m)) return 0
+  return Math.max(0, Math.min(24 * 60, h * 60 + m))
+}
+
+export function minutesToTime(mins: number): string {
+  const clamped = Math.max(0, Math.min(24 * 60 - 1, Math.round(mins)))
+  return `${String(Math.floor(clamped / 60)).padStart(2, '0')}:${String(clamped % 60).padStart(2, '0')}`
+}
+
+/** Minutes a timed event is drawn with when its record carries no end. */
+export const DEFAULT_SPAN_MINUTES: Record<string, number> = {
+  service: 60,
+  message: 30,
+  external: 60,
+  reminder: 30,
+  quote: 60,
+}
+
+/** The smallest block the time grid draws, so a two-minute event is still clickable. */
+export const MIN_BLOCK_MINUTES = 20
+
+export interface TimedSpan {
+  startMins: number
+  endMins: number
+}
+
+/** Where a timed event sits on its day, in minutes. */
+export function eventSpan(event: {
+  type: string
+  time: string | null
+  endTime?: string | null
+}): TimedSpan | null {
+  if (!event.time) return null
+  const startMins = timeToMinutes(event.time)
+  const fallback = DEFAULT_SPAN_MINUTES[event.type] ?? 60
+  let endMins = event.endTime ? timeToMinutes(event.endTime) : startMins + fallback
+  if (endMins <= startMins) endMins = startMins + fallback
+  return { startMins, endMins: Math.min(24 * 60, endMins) }
+}
+
+export interface PositionedItem<T> {
+  item: T
+  startMins: number
+  endMins: number
+  /** Zero-based column within the overlap cluster. */
+  column: number
+  /** How many columns the cluster spans. */
+  columns: number
+}
+
+/**
+ * Side-by-side layout for events that overlap in time, the way every desktop
+ * calendar draws a double booking. Events are grouped into clusters of
+ * mutually overlapping items; each cluster is packed greedily into as few
+ * columns as fit, and every member is told how wide the cluster is so the
+ * columns can share the day's width.
+ */
+export function layoutTimedEvents<T>(
+  items: Array<{ item: T; span: TimedSpan }>,
+  minBlock = MIN_BLOCK_MINUTES
+): PositionedItem<T>[] {
+  const sorted = items
+    .map(({ item, span }) => ({
+      item,
+      startMins: span.startMins,
+      endMins: Math.max(span.endMins, span.startMins + minBlock),
+    }))
+    .sort((a, b) => a.startMins - b.startMins || b.endMins - a.endMins)
+
+  const result: PositionedItem<T>[] = []
+  let cluster: PositionedItem<T>[] = []
+  let columnEnds: number[] = []
+  let clusterEnd = -1
+
+  const flush = () => {
+    for (const p of cluster) p.columns = columnEnds.length
+    result.push(...cluster)
+    cluster = []
+    columnEnds = []
+    clusterEnd = -1
+  }
+
+  for (const entry of sorted) {
+    if (cluster.length > 0 && entry.startMins >= clusterEnd) flush()
+    let column = columnEnds.findIndex((end) => end <= entry.startMins)
+    if (column === -1) {
+      column = columnEnds.length
+      columnEnds.push(entry.endMins)
+    } else {
+      columnEnds[column] = entry.endMins
+    }
+    clusterEnd = Math.max(clusterEnd, entry.endMins)
+    cluster.push({ ...entry, column, columns: 1 })
+  }
+  flush()
+  return result
+}

+ 23 - 15
src/features/dashboard/custom-cards/server-registry.ts

@@ -1,6 +1,8 @@
 import { db } from '@/lib/db'
-import { toSafeDate } from '@/lib/invoice-utils'
 import { PermissionSubject } from '@/lib/permissions'
+import { addZonedDays } from '@/lib/timezone'
+import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { serviceRecordHref } from '@/lib/service-record'
 import type { CardEntity, CardFilter, CustomCardConfig } from './registry'
 import { getField } from './registry'
@@ -60,13 +62,18 @@ function numberCond(operator: string, value: string): Cond | null {
   }
 }
 
-function dateCond(operator: string, value: string): Cond | null {
-  const d = toSafeDate(value)
+/** The value is a bare YYYY-MM-DD: "before" covers the whole of that workshop day. */
+function dateCond(operator: string, value: string, timeZone: string): Cond | null {
+  const d = toSafeWorkshopDate(value, timeZone)
   if (!d) return null
-  return operator === 'after' ? { gte: d } : operator === 'before' ? { lte: d } : null
+  return operator === 'after'
+    ? { gte: d }
+    : operator === 'before'
+      ? { lt: addZonedDays(d, 1, timeZone) }
+      : null
 }
 
-function condFor(entity: CardEntity, filter: CardFilter): Cond | null {
+function condFor(entity: CardEntity, filter: CardFilter, timeZone: string): Cond | null {
   const def = getField(entity, filter.field)
   if (!def) return null
   switch (def.type) {
@@ -75,7 +82,7 @@ function condFor(entity: CardEntity, filter: CardFilter): Cond | null {
     case 'number':
       return numberCond(filter.operator, filter.value)
     case 'date':
-      return dateCond(filter.operator, filter.value)
+      return dateCond(filter.operator, filter.value, timeZone)
     case 'select':
       return filter.operator === 'eq' && def.options?.includes(filter.value)
         ? { equals: filter.value }
@@ -91,8 +98,8 @@ function vehicleTextWhere(operator: string, value: string): Cond | null {
 }
 
 /** Maps a validated filter to a Prisma where fragment, per entity. */
-function whereFor(entity: CardEntity, filter: CardFilter): Cond | null {
-  const cond = condFor(entity, filter)
+function whereFor(entity: CardEntity, filter: CardFilter, timeZone: string): Cond | null {
+  const cond = condFor(entity, filter, timeZone)
   if (!cond) return null
   const field = filter.field
 
@@ -121,9 +128,9 @@ function whereFor(entity: CardEntity, filter: CardFilter): Cond | null {
   }
 }
 
-function buildWhere(config: CustomCardConfig, base: Cond): Cond {
+function buildWhere(config: CustomCardConfig, base: Cond, timeZone: string): Cond {
   const conds = config.filters
-    .map((filter) => whereFor(config.entity, filter))
+    .map((filter) => whereFor(config.entity, filter, timeZone))
     .filter((c): c is Cond => c !== null)
   return conds.length > 0 ? { AND: [base, ...conds] } : base
 }
@@ -135,11 +142,12 @@ export async function runEntityQuery(
   organizationId: string
 ): Promise<CardRow[]> {
   const limit = Math.max(1, Math.min(25, config.limit))
+  const timeZone = await workshopTimeZone(organizationId)
 
   switch (config.entity) {
     case 'vehicles': {
       const rows = await db.vehicle.findMany({
-        where: buildWhere(config, { organizationId, isArchived: false }),
+        where: buildWhere(config, { organizationId, isArchived: false }, timeZone),
         select: {
           id: true,
           licensePlate: true,
@@ -179,7 +187,7 @@ export async function runEntityQuery(
     }
     case 'customers': {
       const rows = await db.customer.findMany({
-        where: buildWhere(config, { organizationId }),
+        where: buildWhere(config, { organizationId }, timeZone),
         select: {
           id: true,
           name: true,
@@ -209,7 +217,7 @@ export async function runEntityQuery(
     }
     case 'workOrders': {
       const rows = await db.serviceRecord.findMany({
-        where: buildWhere(config, { organizationId }),
+        where: buildWhere(config, { organizationId }, timeZone),
         select: {
           id: true,
           invoiceNumber: true,
@@ -253,7 +261,7 @@ export async function runEntityQuery(
     }
     case 'quotes': {
       const rows = await db.quote.findMany({
-        where: buildWhere(config, { organizationId }),
+        where: buildWhere(config, { organizationId }, timeZone),
         select: {
           id: true,
           quoteNumber: true,
@@ -285,7 +293,7 @@ export async function runEntityQuery(
     }
     case 'inventory': {
       const rows = await db.inventoryPart.findMany({
-        where: buildWhere(config, { organizationId }),
+        where: buildWhere(config, { organizationId }, timeZone),
         select: {
           id: true,
           name: true,

+ 9 - 5
src/features/inspections/Actions/inspectionActions.ts

@@ -13,6 +13,8 @@ import { notificationBus } from '@/lib/notification-bus'
 import { isDefect } from '../Lib/conditions'
 import { findCompletionBlockers, summariseBlockers } from '../Lib/completion'
 import { clearedToNull } from '@/lib/clearable'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { startOfZonedDay, zonedDayKey, zonedParts } from '@/lib/timezone'
 
 export async function getInspectionsPaginated(params: {
   page?: number
@@ -667,7 +669,7 @@ export async function createWorkOrderFromInspection(id: string) {
         throw new Error('This inspection has no defects to work on')
       }
 
-      const [settings, org] = await Promise.all([
+      const [settings, org, timeZone] = await Promise.all([
         db.appSetting.findMany({
           where: {
             organizationId,
@@ -683,15 +685,17 @@ export async function createWorkOrderFromInspection(id: string) {
           },
         }),
         db.organization.findUnique({ where: { id: organizationId }, select: { name: true } }),
+        workshopTimeZone(organizationId),
       ])
       const settingsMap: Record<string, string> = {}
       for (const s of settings) settingsMap[s.key] = s.value
 
       const rawPrefix = settingsMap['workshop.invoicePrefix'] ?? '{year}-'
       const now = new Date()
+      const today = zonedParts(now, timeZone)
       const prefix = rawPrefix
-        .replace('{year}', now.getFullYear().toString())
-        .replace('{month}', String(now.getMonth() + 1).padStart(2, '0'))
+        .replace('{year}', String(today.year))
+        .replace('{month}', String(today.month).padStart(2, '0'))
 
       const lastRecord = await db.serviceRecord.findFirst({
         where: { organizationId },
@@ -713,7 +717,7 @@ export async function createWorkOrderFromInspection(id: string) {
           data: {
             organizationId,
             title: `${vehicleName} — inspection repairs`,
-            description: `Raised from the inspection carried out on ${inspection.createdAt.toISOString().slice(0, 10)}.`,
+            description: `Raised from the inspection carried out on ${zonedDayKey(inspection.createdAt, timeZone)}.`,
             type: 'repair',
             status: 'pending',
             vehicleId: inspection.vehicle.id,
@@ -727,7 +731,7 @@ export async function createWorkOrderFromInspection(id: string) {
             // board immediately, and the workshop prices it as it works.
             taxRate: taxEnabled ? Number(settingsMap['workshop.defaultTaxRate']) || 0 : 0,
             taxInclusive: settingsMap['workshop.taxInclusive'] === 'true',
-            serviceDate: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
+            serviceDate: startOfZonedDay(now, timeZone),
             startDateTime: now,
           },
         })

+ 3 - 2
src/features/payments/Actions/paymentActions.ts

@@ -1,6 +1,7 @@
 'use server'
 
-import { toSafeDate } from '@/lib/invoice-utils'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { db } from '@/lib/db'
 import { issueInvoice } from '@/features/invoices/Lib/issueInvoice'
 import { withAuth } from '@/lib/with-auth'
@@ -26,7 +27,7 @@ export async function createPayment(input: unknown) {
         data: {
           serviceRecordId: data.serviceRecordId,
           amount: data.amount,
-          date: toSafeDate(data.date) ?? new Date(),
+          date: toSafeWorkshopDate(data.date, await workshopTimeZone(organizationId)) ?? new Date(),
           method: data.method,
           note: data.note || null,
         },

+ 4 - 2
src/features/portal/Actions/portalActions.ts

@@ -1,6 +1,7 @@
 'use server'
 
-import { toSafeDate } from '@/lib/invoice-utils'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { db } from '@/lib/db'
 import { getCustomerSession, type CustomerSessionData } from '@/lib/customer-session'
 import { notify } from '@/lib/notify'
@@ -400,7 +401,8 @@ export async function createServiceRequest(input: {
     const serviceRequest = await db.serviceRequest.create({
       data: {
         description: input.description,
-        preferredDate: toSafeDate(input.preferredDate) ?? null,
+        preferredDate:
+          toSafeWorkshopDate(input.preferredDate, await workshopTimeZone(organizationId)) ?? null,
         customerId,
         vehicleId: vehicle.id,
         organizationId,

+ 8 - 3
src/features/quotes/Actions/quoteActions.ts

@@ -7,7 +7,9 @@ import { withAuth } from '@/lib/with-auth'
 import { createQuoteSchema, quoteStatusSchema, updateQuoteSchema } from '../Schema/quoteSchema'
 import { revalidatePath } from 'next/cache'
 import { onInventoryChanged } from '@/features/inventory/Lib/onInventoryChanged'
-import { resolveInvoicePrefix, toSafeDate } from '@/lib/invoice-utils'
+import { resolveInvoicePrefix } from '@/lib/invoice-utils'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
 import { copyFile, mkdir } from 'fs/promises'
@@ -253,7 +255,7 @@ export async function createQuote(input: unknown) {
             taxRate: quoteData.taxRate > 0 ? quoteData.taxRate : defaultTaxRate,
             taxInclusive,
             validUntil:
-              toSafeDate(quoteData.validUntil) ??
+              toSafeWorkshopDate(quoteData.validUntil, await workshopTimeZone(organizationId)) ??
               defaultValidUntil(settingsMap['workshop.quoteValidDays']),
             discountType: quoteData.discountType === 'none' ? null : quoteData.discountType,
           },
@@ -315,7 +317,10 @@ export async function updateQuote(input: unknown) {
             vehicleId: clearedToNull(quoteData.vehicleId),
             validUntil:
               quoteData.validUntil !== undefined
-                ? (toSafeDate(quoteData.validUntil) ?? null)
+                ? (toSafeWorkshopDate(
+                    quoteData.validUntil,
+                    await workshopTimeZone(organizationId)
+                  ) ?? null)
                 : undefined,
             discountType: quoteData.discountType === 'none' ? null : quoteData.discountType,
           },

+ 26 - 19
src/features/report-schedule/Actions/reportScheduleActions.ts

@@ -1,7 +1,9 @@
 'use server'
 
-import { toSafeDate } from '@/lib/invoice-utils'
 import { db } from '@/lib/db'
+import { atZonedTime } from '@/lib/timezone'
+import { endOfWorkshopDay, shiftWorkshopTime } from '@/lib/workshop-datetime'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { revalidatePath } from 'next/cache'
@@ -10,37 +12,36 @@ import {
   updateReportScheduleSchema,
 } from '../Schema/reportScheduleSchema'
 
-function calculateNextRunDate(from: Date, frequency: string): Date {
-  const next = new Date(from)
+/** The next run is 08:00 on the workshop's clock, one period on from `from`. */
+function calculateNextRunDate(from: Date, frequency: string, timeZone: string): Date {
+  let next = from
   switch (frequency) {
     case 'daily':
-      next.setDate(next.getDate() + 1)
+      next = shiftWorkshopTime(from, { days: 1 }, timeZone)
       break
     case 'weekly':
-      next.setDate(next.getDate() + 7)
+      next = shiftWorkshopTime(from, { days: 7 }, timeZone)
       break
     case 'biweekly':
-      next.setDate(next.getDate() + 14)
+      next = shiftWorkshopTime(from, { days: 14 }, timeZone)
       break
     case 'monthly':
-      next.setMonth(next.getMonth() + 1)
+      next = shiftWorkshopTime(from, { months: 1 }, timeZone)
       break
     case 'bimonthly':
-      next.setMonth(next.getMonth() + 2)
+      next = shiftWorkshopTime(from, { months: 2 }, timeZone)
       break
     case 'quarterly':
-      next.setMonth(next.getMonth() + 4)
+      next = shiftWorkshopTime(from, { months: 3 }, timeZone)
       break
     case 'semiannually':
-      next.setMonth(next.getMonth() + 6)
+      next = shiftWorkshopTime(from, { months: 6 }, timeZone)
       break
     case 'yearly':
-      next.setFullYear(next.getFullYear() + 1)
+      next = shiftWorkshopTime(from, { years: 1 }, timeZone)
       break
   }
-  // Set to 8:00 AM
-  next.setHours(8, 0, 0, 0)
-  return next
+  return atZonedTime(next, '08:00', timeZone)
 }
 
 export async function getReportSchedules() {
@@ -84,7 +85,8 @@ export async function createReportSchedule(input: unknown) {
   return withAuth(
     async ({ organizationId, userId }) => {
       const data = createReportScheduleSchema.parse(input)
-      const nextRunDate = calculateNextRunDate(new Date(), data.frequency)
+      const timeZone = await workshopTimeZone(organizationId)
+      const nextRunDate = calculateNextRunDate(new Date(), data.frequency, timeZone)
 
       const schedule = await db.reportSchedule.create({
         data: {
@@ -94,7 +96,7 @@ export async function createReportSchedule(input: unknown) {
           sections: JSON.stringify(data.sections),
           recipients: JSON.stringify(data.recipients),
           nextRunDate,
-          endDate: toSafeDate(data.endDate) ?? null,
+          endDate: endOfWorkshopDay(data.endDate, timeZone) ?? null,
           organizationId,
           createdById: userId,
         },
@@ -124,9 +126,10 @@ export async function updateReportSchedule(input: unknown) {
       })
       if (!existing) throw new Error('Schedule not found')
 
+      const timeZone = await workshopTimeZone(organizationId)
       const frequencyChanged = data.frequency !== existing.frequency
       const nextRunDate = frequencyChanged
-        ? calculateNextRunDate(new Date(), data.frequency)
+        ? calculateNextRunDate(new Date(), data.frequency, timeZone)
         : existing.nextRunDate
 
       const schedule = await db.reportSchedule.update({
@@ -138,7 +141,7 @@ export async function updateReportSchedule(input: unknown) {
           sections: JSON.stringify(data.sections),
           recipients: JSON.stringify(data.recipients),
           nextRunDate,
-          endDate: toSafeDate(data.endDate) ?? null,
+          endDate: endOfWorkshopDay(data.endDate, timeZone) ?? null,
           isActive: data.isActive ?? existing.isActive,
         },
       })
@@ -217,7 +220,11 @@ export async function toggleReportSchedule(id: string) {
 
       const isActive = !existing.isActive
       const nextRunDate = isActive
-        ? calculateNextRunDate(new Date(), existing.frequency)
+        ? calculateNextRunDate(
+            new Date(),
+            existing.frequency,
+            await workshopTimeZone(organizationId)
+          )
         : existing.nextRunDate
 
       const schedule = await db.reportSchedule.update({

+ 48 - 61
src/features/reports/Actions/reportActions.ts

@@ -4,20 +4,38 @@ import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { netLineTotal } from '@/lib/tax'
+import { zonedDate, zonedDayKey, zonedParts } from '@/lib/timezone'
+import { workshopDayRange, workshopMonthKey } from '@/lib/workshop-datetime'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+
+/**
+ * The report window on the workshop's calendar. The picker sends bare
+ * YYYY-MM-DD keys, so "to 31 Dec" must cover the whole of that day in the
+ * workshop, and the default start is 1 January of the workshop's current
+ * year, not the server's. `end` is exclusive: query with `lt`.
+ */
+async function reportWindow(
+  organizationId: string,
+  params: { startDate?: string; endDate?: string }
+) {
+  const tz = await workshopTimeZone(organizationId)
+  const now = new Date()
+  const { gte, lt } = workshopDayRange(params.startDate, params.endDate, tz, {
+    start: zonedDate(zonedParts(now, tz).year, 1, 1, 0, 0, tz),
+    end: now,
+  })
+  return { tz, start: gte, end: lt }
+}
 
 export async function getRevenueReport(params: { startDate?: string; endDate?: string }) {
   return withAuth(
     async ({ organizationId }) => {
-      const start = params.startDate
-        ? new Date(params.startDate)
-        : new Date(new Date().getFullYear(), 0, 1)
-      const end = params.endDate ? new Date(params.endDate) : new Date()
-      end.setHours(23, 59, 59, 999)
+      const { tz, start, end } = await reportWindow(organizationId, params)
 
       const records = await db.serviceRecord.findMany({
         where: {
           organizationId,
-          startDateTime: { gte: start, lte: end },
+          startDateTime: { gte: start, lt: end },
         },
         select: {
           serviceDate: true,
@@ -73,7 +91,7 @@ export async function getRevenueReport(params: { startDate?: string; endDate?: s
           0
         )
         const _date = r.startDateTime ?? r.serviceDate
-        const month = `${_date.getFullYear()}-${String(_date.getMonth() + 1).padStart(2, '0')}`
+        const month = workshopMonthKey(_date, tz)
 
         if (!monthly[month])
           monthly[month] = {
@@ -131,16 +149,12 @@ export async function getRevenueReport(params: { startDate?: string; endDate?: s
 export async function getServiceReport(params: { startDate?: string; endDate?: string }) {
   return withAuth(
     async ({ organizationId }) => {
-      const start = params.startDate
-        ? new Date(params.startDate)
-        : new Date(new Date().getFullYear(), 0, 1)
-      const end = params.endDate ? new Date(params.endDate) : new Date()
-      end.setHours(23, 59, 59, 999)
+      const { start, end } = await reportWindow(organizationId, params)
 
       const records = await db.serviceRecord.findMany({
         where: {
           organizationId,
-          startDateTime: { gte: start, lte: end },
+          startDateTime: { gte: start, lt: end },
         },
         select: {
           type: true,
@@ -171,11 +185,7 @@ export async function getServiceReport(params: { startDate?: string; endDate?: s
 export async function getCustomerReport(params: { startDate?: string; endDate?: string }) {
   return withAuth(
     async ({ organizationId }) => {
-      const start = params.startDate
-        ? new Date(params.startDate)
-        : new Date(new Date().getFullYear(), 0, 1)
-      const end = params.endDate ? new Date(params.endDate) : new Date()
-      end.setHours(23, 59, 59, 999)
+      const { start, end } = await reportWindow(organizationId, params)
 
       const customers = await db.customer.findMany({
         where: { organizationId },
@@ -186,7 +196,7 @@ export async function getCustomerReport(params: { startDate?: string; endDate?:
           vehicles: {
             select: {
               serviceRecords: {
-                where: { startDateTime: { gte: start, lte: end } },
+                where: { startDateTime: { gte: start, lt: end } },
                 select: { totalAmount: true, cost: true },
               },
             },
@@ -222,16 +232,12 @@ export async function getCustomerReport(params: { startDate?: string; endDate?:
 export async function getTechnicianReport(params: { startDate?: string; endDate?: string }) {
   return withAuth(
     async ({ organizationId }) => {
-      const start = params.startDate
-        ? new Date(params.startDate)
-        : new Date(new Date().getFullYear(), 0, 1)
-      const end = params.endDate ? new Date(params.endDate) : new Date()
-      end.setHours(23, 59, 59, 999)
+      const { start, end } = await reportWindow(organizationId, params)
 
       const records = await db.serviceRecord.findMany({
         where: {
           organizationId,
-          startDateTime: { gte: start, lte: end },
+          startDateTime: { gte: start, lt: end },
           OR: [{ technicianId: { not: null } }, { techName: { not: null } }],
         },
         select: {
@@ -284,17 +290,13 @@ export async function getTechnicianReport(params: { startDate?: string; endDate?
 export async function getPartsUsageReport(params: { startDate?: string; endDate?: string }) {
   return withAuth(
     async ({ organizationId }) => {
-      const start = params.startDate
-        ? new Date(params.startDate)
-        : new Date(new Date().getFullYear(), 0, 1)
-      const end = params.endDate ? new Date(params.endDate) : new Date()
-      end.setHours(23, 59, 59, 999)
+      const { start, end } = await reportWindow(organizationId, params)
 
       const parts = await db.servicePart.findMany({
         where: {
           serviceRecord: {
             organizationId,
-            startDateTime: { gte: start, lte: end },
+            startDateTime: { gte: start, lt: end },
           },
         },
         select: {
@@ -379,16 +381,12 @@ export async function getPartsUsageReport(params: { startDate?: string; endDate?
 export async function getJobAnalyticsReport(params: { startDate?: string; endDate?: string }) {
   return withAuth(
     async ({ organizationId }) => {
-      const start = params.startDate
-        ? new Date(params.startDate)
-        : new Date(new Date().getFullYear(), 0, 1)
-      const end = params.endDate ? new Date(params.endDate) : new Date()
-      end.setHours(23, 59, 59, 999)
+      const { tz, start, end } = await reportWindow(organizationId, params)
 
       const records = await db.serviceRecord.findMany({
         where: {
           organizationId,
-          startDateTime: { gte: start, lte: end },
+          startDateTime: { gte: start, lt: end },
         },
         select: {
           type: true,
@@ -438,7 +436,7 @@ export async function getJobAnalyticsReport(params: { startDate?: string; endDat
       ]
       const dayCount = [0, 0, 0, 0, 0, 0, 0]
       for (const r of records) {
-        dayCount[(r.startDateTime ?? r.serviceDate).getDay()] += 1
+        dayCount[zonedParts(r.startDateTime ?? r.serviceDate, tz).weekday] += 1
       }
       const dayOfWeek = dayNames.map((day, i) => ({ day, count: dayCount[i] }))
 
@@ -446,7 +444,7 @@ export async function getJobAnalyticsReport(params: { startDate?: string; endDat
       const monthly: Record<string, { count: number; revenue: number }> = {}
       for (const r of records) {
         const _d = r.startDateTime ?? r.serviceDate
-        const month = `${_d.getFullYear()}-${String(_d.getMonth() + 1).padStart(2, '0')}`
+        const month = workshopMonthKey(_d, tz)
         if (!monthly[month]) monthly[month] = { count: 0, revenue: 0 }
         monthly[month].count += 1
         monthly[month].revenue += r.totalAmount > 0 ? r.totalAmount : r.cost
@@ -470,11 +468,7 @@ export async function getJobAnalyticsReport(params: { startDate?: string; endDat
 export async function getCustomerRetentionReport(params: { startDate?: string; endDate?: string }) {
   return withAuth(
     async ({ organizationId }) => {
-      const start = params.startDate
-        ? new Date(params.startDate)
-        : new Date(new Date().getFullYear(), 0, 1)
-      const end = params.endDate ? new Date(params.endDate) : new Date()
-      end.setHours(23, 59, 59, 999)
+      const { start, end } = await reportWindow(organizationId, params)
 
       const customers = await db.customer.findMany({
         where: { organizationId },
@@ -485,7 +479,7 @@ export async function getCustomerRetentionReport(params: { startDate?: string; e
           vehicles: {
             select: {
               serviceRecords: {
-                where: { startDateTime: { gte: start, lte: end } },
+                where: { startDateTime: { gte: start, lt: end } },
                 select: { serviceDate: true, startDateTime: true, totalAmount: true, cost: true },
                 orderBy: [
                   { startDateTime: { sort: 'asc', nulls: 'last' } },
@@ -613,6 +607,7 @@ export async function getPastDueInvoicesReport() {
   return withAuth(
     async ({ organizationId }) => {
       const now = new Date()
+      const tz = await workshopTimeZone(organizationId)
 
       const records = await db.serviceRecord.findMany({
         where: {
@@ -684,7 +679,7 @@ export async function getPastDueInvoicesReport() {
           totalAmount: total,
           amountPaid: paid,
           amountDue,
-          dueDate: dueDate.toISOString().split('T')[0],
+          dueDate: zonedDayKey(dueDate, tz),
           daysPastDue,
         })
 
@@ -717,11 +712,7 @@ export async function getVehicleReport(params: {
 }) {
   return withAuth(
     async ({ organizationId }) => {
-      const start = params.startDate
-        ? new Date(params.startDate)
-        : new Date(new Date().getFullYear(), 0, 1)
-      const end = params.endDate ? new Date(params.endDate) : new Date()
-      end.setHours(23, 59, 59, 999)
+      const { tz, start, end } = await reportWindow(organizationId, params)
 
       const vehicle = await db.vehicle.findFirst({
         where: { id: params.vehicleId, organizationId },
@@ -742,7 +733,7 @@ export async function getVehicleReport(params: {
         where: {
           vehicleId: params.vehicleId,
           organizationId,
-          startDateTime: { gte: start, lte: end },
+          startDateTime: { gte: start, lt: end },
         },
         select: {
           id: true,
@@ -806,7 +797,7 @@ export async function getVehicleReport(params: {
         typeCounts[r.type].totalCost += total
 
         const _d = r.startDateTime ?? r.serviceDate
-        const month = `${_d.getFullYear()}-${String(_d.getMonth() + 1).padStart(2, '0')}`
+        const month = workshopMonthKey(_d, tz)
         if (!monthly[month]) monthly[month] = { partsCost: 0, laborCost: 0, totalCost: 0, count: 0 }
         monthly[month].partsCost += partsCost
         monthly[month].laborCost += laborCost
@@ -866,7 +857,7 @@ export async function getVehicleReport(params: {
           title: r.title,
           type: r.type,
           status: r.status,
-          date: (r.startDateTime ?? r.serviceDate).toISOString().split('T')[0],
+          date: zonedDayKey(r.startDateTime ?? r.serviceDate, tz),
           totalAmount: r.totalAmount > 0 ? r.totalAmount : r.cost,
           partsCount: r.partItems.reduce((s, p) => s + p.quantity, 0),
           laborHours: r.laborItems.reduce((s, l) => s + l.hours, 0),
@@ -881,16 +872,12 @@ export async function getVehicleReport(params: {
 export async function getTaxReport(params: { startDate?: string; endDate?: string }) {
   return withAuth(
     async ({ organizationId }) => {
-      const start = params.startDate
-        ? new Date(params.startDate)
-        : new Date(new Date().getFullYear(), 0, 1)
-      const end = params.endDate ? new Date(params.endDate) : new Date()
-      end.setHours(23, 59, 59, 999)
+      const { tz, start, end } = await reportWindow(organizationId, params)
 
       const records = await db.serviceRecord.findMany({
         where: {
           organizationId,
-          startDateTime: { gte: start, lte: end },
+          startDateTime: { gte: start, lt: end },
         },
         select: {
           serviceDate: true,
@@ -917,7 +904,7 @@ export async function getTaxReport(params: { startDate?: string; endDate?: strin
         if (r.taxAmount <= 0) continue
 
         const _dt = r.startDateTime ?? r.serviceDate
-        const month = `${_dt.getFullYear()}-${String(_dt.getMonth() + 1).padStart(2, '0')}`
+        const month = workshopMonthKey(_dt, tz)
 
         // The taxable base = total - tax. This is the net amount on which tax
         // was calculated, and it works in BOTH inclusive and exclusive modes

+ 28 - 14
src/features/scheduled-messages/Actions/scheduledMessageActions.ts

@@ -10,6 +10,8 @@ import {
   updateScheduledMessageSchema,
 } from '../Schema/scheduledMessageSchema'
 import { dispatchScheduledMessage, nextSendAt } from '../Lib/dispatchScheduledMessage'
+import { parseWorkshopDateTime, workshopDayRange } from '@/lib/workshop-datetime'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
 
 export type ScheduledMessageListItem = {
   id: string
@@ -49,11 +51,17 @@ const listSelect = {
  * Parse the local wall-clock the workshop typed ("2026-08-20T09:00") as local
  * time, never UTC, so the message goes out at the hour they see on screen.
  */
-function parseLocalDateTime(value: string): Date {
-  const normalized = /\d{2}:\d{2}$/.test(value) ? `${value}:00` : value
-  const date = new Date(normalized)
-  if (Number.isNaN(date.getTime())) throw new Error('Invalid send time')
-  return date
+/** A minute of grace, so a form filled in at 09:00 and saved at 09:00:20 still goes. */
+const PAST_GRACE_MS = 60_000
+
+/**
+ * A message cannot be scheduled for a moment that has passed: the dispatcher
+ * would fire it at once, and nobody meant that.
+ */
+function assertNotPast(sendAt: Date) {
+  if (sendAt.getTime() < Date.now() - PAST_GRACE_MS) {
+    throw new Error('The send time has already passed')
+  }
 }
 
 export async function getScheduledMessages(params?: { status?: string }) {
@@ -82,15 +90,16 @@ export async function getScheduledMessages(params?: { status?: string }) {
 export async function getScheduledMessagesInRange(params: { start: string; end: string }) {
   return withAuth(
     async ({ organizationId }) => {
-      const start = new Date(params.start)
-      const end = new Date(params.end)
-      end.setHours(23, 59, 59, 999)
+      // The keys are workshop days, so the window is whole days on its clock
+      const timeZone = await workshopTimeZone(organizationId)
+      const now = new Date()
+      const sendAt = workshopDayRange(params.start, params.end, timeZone, { start: now, end: now })
 
       return db.scheduledMessage.findMany({
         where: {
           organizationId,
           status: { not: 'cancelled' },
-          sendAt: { gte: start, lte: end },
+          sendAt,
         },
         select: listSelect,
         orderBy: { sendAt: 'asc' },
@@ -109,7 +118,9 @@ export async function createScheduledMessage(input: unknown) {
     async ({ organizationId, userId }) => {
       demoGuard()
       const data = createScheduledMessageSchema.parse(input)
-      const sendAt = parseLocalDateTime(data.sendAt)
+      const timeZone = await workshopTimeZone(organizationId)
+      const sendAt = parseWorkshopDateTime(data.sendAt, timeZone)
+      assertNotPast(sendAt)
 
       if (data.customerId) {
         const customer = await db.customer.findFirst({
@@ -136,7 +147,7 @@ export async function createScheduledMessage(input: unknown) {
           vehicleId: data.vehicleId || null,
           sendAt,
           frequency: data.frequency,
-          endDate: data.endDate ? parseLocalDateTime(data.endDate) : null,
+          endDate: data.endDate ? parseWorkshopDateTime(data.endDate, timeZone) : null,
           organizationId,
           createdById: userId,
         },
@@ -175,6 +186,8 @@ export async function updateScheduledMessage(input: unknown) {
         select: { id: true },
       })
       if (!existing) throw new Error('Scheduled message not found')
+      const timeZone = await workshopTimeZone(organizationId)
+      if (data.sendAt) assertNotPast(parseWorkshopDateTime(data.sendAt, timeZone))
 
       const message = await db.scheduledMessage.update({
         where: { id: data.id },
@@ -185,10 +198,10 @@ export async function updateScheduledMessage(input: unknown) {
           ...(data.recipient !== undefined ? { recipient: data.recipient?.trim() || null } : {}),
           ...(data.customerId !== undefined ? { customerId: data.customerId || null } : {}),
           ...(data.vehicleId !== undefined ? { vehicleId: data.vehicleId || null } : {}),
-          ...(data.sendAt ? { sendAt: parseLocalDateTime(data.sendAt) } : {}),
+          ...(data.sendAt ? { sendAt: parseWorkshopDateTime(data.sendAt, timeZone) } : {}),
           ...(data.frequency ? { frequency: data.frequency } : {}),
           ...(data.endDate !== undefined
-            ? { endDate: data.endDate ? parseLocalDateTime(data.endDate) : null }
+            ? { endDate: data.endDate ? parseWorkshopDateTime(data.endDate, timeZone) : null }
             : {}),
           ...(data.status ? { status: data.status } : {}),
           // Rescheduling clears the last failure, so a fixed address doesn't
@@ -304,7 +317,8 @@ export async function sendScheduledMessageNow(id: string) {
       if (!message) throw new Error('Scheduled message not found')
 
       const now = new Date()
-      const following = nextSendAt(message.sendAt, message.frequency, message.endDate)
+      const timeZone = await workshopTimeZone(organizationId)
+      const following = nextSendAt(message.sendAt, message.frequency, message.endDate, timeZone)
 
       try {
         await dispatchScheduledMessage(message)

+ 12 - 4
src/features/scheduled-messages/Components/ScheduleMessageDialog.tsx

@@ -60,6 +60,8 @@ interface ScheduleMessageDialogProps {
   message?: ScheduleMessageValues
   /** YYYY-MM-DD the message goes out on, e.g. the calendar day that was right-clicked */
   defaultDate?: string
+  /** HH:MM it goes out at, when the calendar was clicked on a time slot */
+  defaultTime?: string
   defaultCustomer?: { id: string; name: string; company: string | null } | null
   /** Pre-filled text, e.g. a draft handed over from the compose dialog */
   defaultBody?: string
@@ -83,6 +85,7 @@ export function ScheduleMessageDialog({
   availableChannels,
   message,
   defaultDate,
+  defaultTime,
   defaultCustomer = null,
   defaultBody,
   onSaved,
@@ -127,11 +130,11 @@ export function ScheduleMessageDialog({
       setSubject('')
       setBody(defaultBody ?? '')
       setDate(defaultDate ?? toLocalDateStr(new Date()))
-      setTime('09:00')
+      setTime(defaultTime ?? '09:00')
       setFrequency('once')
       setEndDate('')
     }
-  }, [open, message, defaultDate, defaultBody, defaultCustomer?.id, availableChannels])
+  }, [open, message, defaultDate, defaultTime, defaultBody, defaultCustomer?.id, availableChannels])
 
   // A scheduled message fires later, when the customer's 24 hour window has
   // almost certainly closed, so it goes as a template or not at all.
@@ -160,9 +163,13 @@ export function ScheduleMessageDialog({
     (channel !== 'email' || !!subject.trim()) &&
     (!needsRecipient || !!recipient.trim())
 
+  // The browser's clock stands in for the workshop's here, as everywhere on
+  // the client; the server checks again in the workshop's zone.
+  const inPast = !!date && !!time && new Date(`${date}T${time}:00`).getTime() < Date.now()
+
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
-    if (!canSave) return
+    if (!canSave || inPast) return
     setSaving(true)
 
     const payload = {
@@ -317,6 +324,7 @@ export function ScheduleMessageDialog({
                 onChange={(e) => setTime(e.target.value)}
                 required
               />
+              {inPast && <p className="text-xs text-destructive">{t('inPast')}</p>}
             </div>
           </div>
 
@@ -332,7 +340,7 @@ export function ScheduleMessageDialog({
             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
               {tc('cancel')}
             </Button>
-            <Button type="submit" disabled={saving || !canSave}>
+            <Button type="submit" disabled={saving || !canSave || inPast}>
               {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
               {isEdit ? tc('saveChanges') : t('schedule')}
             </Button>

+ 11 - 8
src/features/scheduled-messages/Lib/dispatchScheduledMessage.ts

@@ -4,6 +4,7 @@ import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
 import { sendOrgSms, getOrgSmsPhoneNumber, normalizeOrgPhone } from '@/lib/sms'
 import { sendTelegramMessage } from '@/lib/telegram'
 import { sendOrgWhatsapp } from '@/lib/whatsapp'
+import { shiftWorkshopTime } from '@/lib/workshop-datetime'
 import type { MessageFrequency } from '../Schema/scheduledMessageSchema'
 
 /** Everything a send needs, whether it comes from the cron or a manual push. */
@@ -176,29 +177,31 @@ export async function dispatchScheduledMessage(message: DispatchableMessage): Pr
 
 /**
  * When a repeating message goes out next, or null when it is done.
- * Keeps the wall-clock time of day and stops once past `endDate`.
+ * Keeps the wall-clock time of day on the workshop's clock, so a daily
+ * 18:00 stays 18:00 across a DST change, and stops once past `endDate`.
  */
 export function nextSendAt(
   current: Date,
   frequency: MessageFrequency | string,
-  endDate: Date | null
+  endDate: Date | null,
+  timeZone: string
 ): Date | null {
-  const next = new Date(current)
+  let next: Date
   switch (frequency) {
     case 'daily':
-      next.setDate(next.getDate() + 1)
+      next = shiftWorkshopTime(current, { days: 1 }, timeZone)
       break
     case 'weekly':
-      next.setDate(next.getDate() + 7)
+      next = shiftWorkshopTime(current, { days: 7 }, timeZone)
       break
     case 'biweekly':
-      next.setDate(next.getDate() + 14)
+      next = shiftWorkshopTime(current, { days: 14 }, timeZone)
       break
     case 'monthly':
-      next.setMonth(next.getMonth() + 1)
+      next = shiftWorkshopTime(current, { months: 1 }, timeZone)
       break
     case 'yearly':
-      next.setFullYear(next.getFullYear() + 1)
+      next = shiftWorkshopTime(current, { years: 1 }, timeZone)
       break
     default:
       return null // "once"

+ 6 - 2
src/features/status-reports/Actions/createStatusReport.ts

@@ -1,6 +1,7 @@
 'use server'
 
-import { toSafeDate } from '@/lib/invoice-utils'
+import { workshopTimeZone } from '@/lib/workshop-timezone'
+import { endOfWorkshopDay } from '@/lib/workshop-datetime'
 import { randomBytes } from 'crypto'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
@@ -36,7 +37,10 @@ export async function createStatusReport(input: unknown) {
           organizationId,
           technicianId: technician?.id || serviceRecord.technicianId,
           status: data.videoUrl ? 'published' : 'draft',
-          expiresAt: toSafeDate(data.expiresAt) ?? new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
+          // A chosen day means the link lives through the whole of that day.
+          expiresAt:
+            endOfWorkshopDay(data.expiresAt, await workshopTimeZone(organizationId)) ??
+            new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
         },
       })
 

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů