# My Calendar Module Upgrade: Recurring Events, Filter Redesign, Readable Event Display, Floating Widget

## Problem

The My Calendar module (`/my_calendar`, `PagesController::myCalendarList` / `myCalendarData` / `myCalendarStore`) has four usability gaps compared to the tools it's most often compared to, Google Calendar and Outlook:

1. **No recurrence.** `add_my_calendar_modal_content.blade.php` only supports one-off events. Staff who want a weekly team meeting or a monthly reminder must manually re-create the event every time.
2. **Filters are hidden behind a single dropdown button.** The "Filter Options" panel (`my_calendar.blade.php:30-116`) is a `menu-sub-dropdown` triggered by one small "Filter" button, inconsistent with the always-visible accordion filter pattern already used elsewhere in the app (e.g. `resources/views/backend/roster_client_v2/staff_roster_time/list.blade.php`, route `team_roster_staff_timesheet`). The Event Type filter is also single-select, so a user can't view (for example) both "Roster shifts" and "Approved leaves" at once.
3. **Event text is unreadable when a day has several events.** Current CSS (`my_calendar.blade.php:7-20`) forces `white-space: nowrap` and `text-overflow: ellipsis` on `.fc-event`, so most titles are cut off mid-word in the month/week grid, recoverable only via a hover tooltip.
4. **No quick-glance access from outside the My Calendar page.** Checking upcoming events today means navigating away to `/my_calendar` entirely, unlike Outlook's always-available week-at-a-glance side panel. The app already has a floating-toolbar pattern for this kind of quick access (Chats, Help) that My Calendar doesn't participate in.

This spec covers all four, scoped to the My Calendar module and the app's existing floating "engage toolbar" (`resources/views/partials/engage/`). No other calendar surface in the app is affected.

## Current Architecture (relevant pieces)

- **Routes** (`routes/web.php:918-923`): `my_calendar.index` (GET, list page), `my_calendar.post.index` (POST, FullCalendar's event feed), `my_calendar_edit_modal` (POST, returns edit modal HTML), `my_calendar.store` (POST, create/update), `my_calendar.delete` (DELETE).
- **Controller** (`app/Http/Controllers/Backend/PagesController.php`): `myCalendarList()` builds filter option lists and renders the page; `myCalendarData()` assembles the FullCalendar event feed from four independent sources — computed birthdays, computed work anniversaries, `LeaveRequest` (approved leave), and roster shifts — filtered by `users_list`, `client_list`, `shift_type`, `event_type` query params, each defaulting to "all" when `-1`/absent; `myCalendarSave()` (called by `myCalendarStore`) validates and upserts a single `CalendarEvent` row plus its `CalendarEventUser` pivot rows.
- **Model** (`app/Models/CalendarEvent.php`): thin Eloquent model over the `calendar_events` table (`id, client_id, title, start_date, start_time, end_date, end_time, all_day, type, description, user_id, email, notification, app_notification, reference_id, reference_type, timestamps`). No soft deletes currently (`myCalendarDestroy` hard-deletes).
- **Frontend**: FullCalendar v5.10.1 (static bundle at `public/theme1/plugins/custom/fullcalendar/`), rendered in `my_calendar.blade.php`; event feed fetched via AJAX from `my_calendar.post.index` with `users_list`/`client_list`/`shift_type`/`event_type` as `extraParams`.
- **Reminders**: `app/Console/Commands/CalendarEventReminder.php` (`send:calendar_event_reminder`) queries `CalendarEvent::where('email', 1)->whereIn('start_date', [...])` etc. directly against plain rows — it has no awareness of recurrence and needs none, since every occurrence will be a normal row (see Design §1).

## Design

### 1. Recurrence data model

**New table `calendar_event_recurrences`** — the repeat rule for a series:

| Column | Type | Notes |
|---|---|---|
| `id` | bigIncrements | PK |
| `frequency` | string | `daily` \| `weekly` \| `monthly` \| `yearly` |
| `interval` | unsignedSmallInteger, default 1 | repeat every N units of `frequency` |
| `by_weekdays` | json, nullable | e.g. `["MO","WE","FR"]`; used when `frequency = weekly` (including the "Every weekday" preset, which is `interval=1, by_weekdays=["MO","TU","WE","TH","FR"]`) |
| `monthly_type` | string, nullable | `day_of_month` \| `weekday_of_month`; only read when `frequency = monthly` |
| `ends_type` | string | `never` \| `on_date` \| `after_count` |
| `ends_on_date` | date, nullable | set when `ends_type = on_date` |
| `ends_after_count` | unsignedInteger, nullable | set when `ends_type = after_count` |
| `occurrences_generated` | unsignedInteger, default 0 | running count of materialized occurrences, compared against `ends_after_count` |
| `horizon_generated_until` | date | furthest date occurrences have been materialized to |
| `user_id` | bigInteger | series owner, mirrors `calendar_events.user_id` |
| `created_at`, `updated_at`, `deleted_at` | timestamps + soft delete | |

**New columns on `calendar_events`:**

| Column | Type | Notes |
|---|---|---|
| `recurrence_id` | unsignedBigInteger, nullable, FK → `calendar_event_recurrences.id` | null for one-off events |
| `recurrence_original_date` | date, nullable | the date this occurrence was originally generated for; used to find the cut point for "this and following" operations |
| `is_recurrence_exception` | boolean, default false | true once this single occurrence has been edited independently; series-wide ("all events") edits skip exception rows |
| `deleted_at` | timestamp, nullable (soft delete) | `calendar_events` currently hard-deletes; recurrence support requires soft delete so "this and following"/"all" deletes can be applied and reversed consistently. `myCalendarDestroy` switches from hard delete to `->delete()` via `SoftDeletes`. |

**Generation.** On creating a repeating event, a `CalendarRecurrenceGenerator` service computes concrete occurrence dates from the rule and inserts one `calendar_events` row per occurrence, up to `min(ends_on_date, today + 12 months)` if `ends_type = never`, or up to `ends_after_count` occurrences, whichever the rule specifies. Each row is a full copy of the event's fields (title, times, participant, users, reminder flags) plus `recurrence_id` and `recurrence_original_date` set. This means every existing code path that reads `calendar_events` — the FullCalendar feed, the reminder cron, exports — works against recurring events with zero special-casing, since each occurrence is an ordinary row.

**Horizon extension.** A new scheduled command, `php artisan calendar:extend_recurring_events` (class `App\Console\Commands\GenerateRecurringCalendarEvents`), registered in `app/Console/Kernel.php` to run **monthly**, finds every `calendar_event_recurrences` row where `ends_type = 'never'` or (`ends_type = 'after_count'` and `occurrences_generated < ends_after_count`), and extends `horizon_generated_until` by one month, generating the newly-covered occurrences. Registered with `->withoutOverlapping()->onOneServer()->runInBackground()`, matching every other scheduled task in `Kernel.php`.

**Edit/delete scope semantics** (Google Calendar-style), applied by a `CalendarRecurrenceScopeService` invoked from `myCalendarSave()` / `myCalendarDestroy()` whenever the target row has a `recurrence_id`:

- **`single`** ("This event"): edit → updates only the target row's fields and sets `is_recurrence_exception = true`. Delete → soft-deletes only the target row.
- **`following`** ("This and following events"): edit → (a) caps the current series' `ends_on_date` to the day before the target occurrence's `recurrence_original_date` (and sets `ends_type = 'on_date'` if it was `never`/`after_count`), (b) creates a new `calendar_event_recurrences` row (cloning the — possibly just-edited — rule) starting at the target occurrence's date, (c) re-points every occurrence from the target date onward to the new series id, (d) applies the edit to those rows. Delete → soft-deletes the target row and every later row in the series, and caps the series' `ends_on_date` the same way as the edit case (so the horizon job doesn't regenerate them).
- **`all`** ("All events"): edit → updates the `calendar_event_recurrences` rule and every non-exception occurrence row tied to it (exception rows keep their per-occurrence overrides). Delete → soft-deletes every row tied to `recurrence_id` and soft-deletes the series row itself.

A one-off event (`recurrence_id` is null) bypasses this service entirely — Save/Delete behave exactly as they do today.

### 2. Add/Edit Event UX — Repeat

**Field:** a `repeat_option` select is added to `add_my_calendar_modal_content.blade.php` and `edit_my_calendar_modal_content.blade.php`, directly below the Start Date/End Date row and above the "All Day" checkbox.

**Preset options** (labels computed client-side from the selected Start Date, so "Weekly on Wednesday" etc. always matches the actual day picked):

1. Does not repeat *(default)*
2. Daily
3. Weekly on `[Weekday]`
4. Monthly on the `[nth]` `[Weekday]`
5. Annually on `[Month]` `[Day]`
6. Every weekday (Monday to Friday)
7. Custom…

Selecting **Custom…** reveals an inline panel (same form, no separate modal):
- "Repeat every `[N]` `[Day(s)/Week(s)/Month(s)/Year(s)]`" — interval + unit inputs
- Weekday checkboxes (Mon–Sun), shown only when unit = Week(s)
- "Ends" radio group: `Never` / `On [date picker]` / `After [N] occurrences`

Any option other than "Does not repeat" populates hidden fields — `frequency`, `interval`, `by_weekdays`, `monthly_type`, `ends_type`, `ends_on_date`, `ends_after_count` — submitted alongside the existing event fields. These map 1:1 onto `calendar_event_recurrences` columns (§1).

**Create (`myCalendarStore` → `myCalendarSave`):** if `repeat_option != does_not_repeat`, the controller creates the `calendar_event_recurrences` row and calls `CalendarRecurrenceGenerator` instead of inserting a single `CalendarEvent`.

**Edit/Delete on a recurring occurrence:** clicking Save or Delete on an occurrence whose `recurrence_id` is set first shows a scope-choice prompt (SweetAlert2, consistent with the existing `Swal.fire` usage in `my_calendar_script.blade.php`) with three options — **This event / This and following events / All events** — matching Google Calendar's own prompt. The choice is sent as `scope` (`single`/`following`/`all`) to `myCalendarStore`/`myCalendarDestroy`, which apply §1's `CalendarRecurrenceScopeService`. Non-recurring events skip the prompt entirely.

**Edit modal Repeat field:** the Repeat dropdown is hidden when the chosen scope is "This event" (a single occurrence's recurrence can't be changed in isolation — that's what "This and following" is for). When scope is "This and following" or "All events", it shows the series' current rule and can be changed, which triggers §1's series-split or series-rule-update logic accordingly.

### 3. Filters Redesign

**Layout.** Remove the `menu-sub-dropdown`/"Filter" button pattern (`my_calendar.blade.php:30-116`) entirely. In its place, add a `kt_accordion_1`-style accordion (matching `resources/views/backend/roster_client_v2/staff_roster_time/list.blade.php:10-116`) titled "Filters", collapsed by default, positioned directly above the `#calander_card` card.

**Fields retained inside the accordion**, laid out in the same `col-lg-3 col-md-3 col-sm-6 col-xs-12` grid, each unchanged in behavior from today:
- Select Participant (`client_list`)
- Select User (`users_list`)
- Select Shift Type (`shift_type`)

**Apply / Reset Filter** buttons sit at the bottom-right of the accordion body, replacing `#applyFilters`/`#resetFilters`, preserving the existing `insertUrlParam` URL-persistence behavior and calling `calendar.refetchEvents()` on Apply.

**Event Type becomes checkboxes.** Removed from the accordion; a checkbox row — **Approved leaves / Birthday / Roster shifts / Work Anniversary / Other** — is added above the `#calendar` div inside the card body. All five are checked by default. Toggling any checkbox immediately calls `calendar.refetchEvents()` (no Apply step for this control, per your instruction that it should auto-refresh).

**Backend param change.** `event_type` changes from a single scalar (`-1` or one key) to `event_type[]`, an array of checked keys, sent as an `extraParams` array in the FullCalendar feed request. In `myCalendarData()`, each category block's guard changes from `empty($event_type) || $event_type == 'leave'` to `in_array('leave', $event_type ?? [])`. Unlike today (where "no filter" implicitly means "show all"), an **empty array means show nothing** — this is a real, reachable state (user unchecks every box), distinct from the default (all five checked = today's unfiltered behavior).

**Reset behavior.** The accordion's Reset button clears participant/user/shift-type back to "All" **and** re-checks all five event-type boxes, then calls `refetchEvents()` once — preserving today's combined single-reset behavior.

### 4. Calendar Event Display — readable "box" view

All changes are FullCalendar v5.10.1 config/CSS — no new library, confirmed compatible with the currently bundled version.

- **Wrapping instead of truncation.** Remove `white-space: nowrap` / `text-overflow: ellipsis` / `overflow: hidden` from `.fc-event` and `.fc-h-event .fc-event-main` (`my_calendar.blade.php:7-20`); replace with `white-space: normal; word-break: break-word;` so titles wrap onto multiple lines inside the event box instead of being cut off.
- **Row capping.** Replace `dayMaxEvents: true` with `dayMaxEventRows: 3`, capping stacked events per day cell at 3 rows before FullCalendar's built-in **"+N more"** popover takes over (showing the rest with full, unwrapped text) — keeps day cells from growing unboundedly tall on busy days while every event stays fully readable somewhere.
- **Smaller font, as a secondary aid.** `.fc-event` font-size drops from `13px` to `12px` with tightened padding — additive to wrapping, not a replacement for it.
- **Richer `eventContent`.** The current single-line `eventContent` callback (`my_calendar.blade.php:302-308`) is replaced with one that renders the title in bold and, when the cell has room, the `description` beneath it as a smaller sub-line — surfacing more of the useful text directly in the box instead of requiring a hover. This sub-line is skipped in `listMonth` view, which already shows full untruncated text natively via its list layout; `listMonth` itself is unchanged and remains available via the existing view-switcher.

### 5. Floating "My Calendar" Widget

The app already has a KTUI "engage toolbar" pattern (`resources/views/partials/engage/_main.blade.php`) — a stack of floating toggle buttons (Demos, Help, Chats) fixed to the right edge of the screen, each opening a `data-kt-drawer` slide-out panel (e.g. `partials/engage/help/_main.blade.php`, `partials/engage/chats/_main.blade.php`). This section adds a new "My Calendar" entry to that same stack, positioned directly below the Chats toggle, opening a compact weekly-agenda drawer.

**Toggle button.** New partial `resources/views/partials/engage/my_calendar/__toggle.blade.php`, styled identically to the existing Chats/Help toggles (`btn btn-flex h-35px bg-body btn-color-gray-700 btn-active-color-gray-900 shadow-sm px-5 rounded-top-0`, Bootstrap tooltip "My Calendar"). Registered in `partials/engage/_main.blade.php`'s `engage-toolbar` block immediately after the Chats toggle, so it renders below Chats in the vertically-stacked toolbar. Wrapped only in `@if(auth()->check())` — no permission gate, matching Help's unrestricted visibility (unlike Chats, which is gated behind `admin.chat_toggle_bar.view`).

**Drawer.** New partial `resources/views/partials/engage/my_calendar/_main.blade.php`, a `data-kt-drawer="true"` panel (`data-kt-drawer-name="my_calendar"`, same width/direction convention as the Chats/Help drawers) toggled by `#kt_my_calendar_toggle`, closed by `#kt_my_calendar_close` — reuses the existing KTUI drawer JS with no new frontend framework.

**Layout**, matching the referenced Outlook-style screenshot:
- **Header:** "Calendar" title, a "+" icon button, and a "…" kebab menu (holding a "Today" shortcut).
- **Date-range subheader:** "`[Start] - [End] '[YY]`" (e.g. "12 Jul - 18 Jul 26") with `<`/`>` chevrons to page the widget one week at a time, a target/"Today" icon to jump back to the current week, and a calendar/date-picker icon to jump to an arbitrary week.
- **Day strip:** Su–Sa columns showing each date of the selected week; the current day is circle-highlighted; a small dot under a date indicates it has at least one event (as in the screenshot).
- **Agenda list:** one row per day of the selected week, each showing either "No events" or the day's event titles, reusing the same title-building convention already used in `myCalendarData()` (e.g. "`[Name] - Birthday`", "`[Name] - On Leave`").
- **Footer:** a "View full calendar" link navigating to `my_calendar.index`, mirroring the Help drawer's "Visit Support Center" link.

**Data & scope.** Always scoped to the logged-in user only (`user_id = auth()->id()`) — no participant/staff/shift-type filter UI in the widget, regardless of the viewer's admin permissions. A new controller method, `myCalendarWidgetData(Request $request)`, accepts a `week_start` param (defaults to the current week) and returns the same four event categories as `myCalendarData()` — birthdays, work anniversaries, approved leave, roster shifts — plus the user's own custom `CalendarEvent` rows, grouped by date rather than FullCalendar's flat array. Materialized recurring occurrences (§1) need no special handling here, since they're plain `calendar_events` rows like any other. New route: `GET my_calendar/widget_data` → `my_calendar.widget_data`.

**Interactions.** Clicking "+" opens the existing Add Event modal (`add_my_calendar_modal_content.blade.php`, already included on pages where the engage toolbar renders, or lazily included alongside the new drawer partial); clicking a day row with no events pre-fills that day's date into the same modal. Clicking an existing event opens the existing Edit modal (`myCalendarEditModal`), including the Section 2 recurrence scope prompt when the clicked event has a `recurrence_id`. No new modal markup is introduced — the drawer reuses the modals already shipped with the full My Calendar page.

### 6. API/Controller Changes Summary

| Endpoint | Change |
|---|---|
| `myCalendarStore` (`myCalendarSave`) | Accepts new recurrence fields (§1) and `scope` param when editing an existing recurring occurrence; delegates to `CalendarRecurrenceGenerator` (create) or `CalendarRecurrenceScopeService` (edit) when a repeat rule is present. |
| `myCalendarDestroy` | Accepts `scope` param; delegates to `CalendarRecurrenceScopeService` for recurring occurrences; switches from hard delete to soft delete (`SoftDeletes` trait added to `CalendarEvent`). |
| `myCalendarData` | `event_type` param changes from scalar to array (`event_type[]`); empty array now means zero event-type categories shown, matching the new checkbox UI. |
| `myCalendarEditModal` | Passes the occurrence's `recurrence_id`/series summary to the edit modal so the frontend knows whether to show the scope prompt and Repeat field. |
| *(new)* `GET my_calendar/recurrence_summary/{recurrence_id}` | Optional lightweight endpoint returning the human-readable rule summary (e.g. "Weekly on Wednesday, until 12 Dec 2026") for display in the edit modal — avoids re-deriving the summary in JS. |
| *(new)* `GET my_calendar/widget_data` (`myCalendarWidgetData`) | Returns the current user's events for a given week, grouped by date, for the floating widget (§5). Always self-scoped; ignores participant/user/shift-type/event-type filters. |

### 7. Scheduler

New command `App\Console\Commands\GenerateRecurringCalendarEvents` (signature `calendar:extend_recurring_events`), registered in `app/Console/Kernel.php`:

```php
$schedule->command('calendar:extend_recurring_events')
    ->monthly()
    ->withoutOverlapping()
    ->onOneServer()
    ->runInBackground();
```

Consistent with the existing scheduler safeguards documented in `CLAUDE.md`.

## Out of Scope

- Changing the recurrence/filter/display behavior of any calendar other than My Calendar (e.g. the roster calendar, master payroll calendar).
- Importing/exporting recurring events as iCal (`.ics`)/RRULE strings.
- Timezone-aware recurrence (existing My Calendar events have no timezone handling today; this spec doesn't add any).
- Notifying already-invited users when a recurring series' schedule changes (existing reminder cron picks up the regenerated rows' `start_date` naturally; no separate "series changed" notification is added).
- A user-level density toggle (Compact/Full text) for event display — deferred per your choice of the single wrapping-box approach in Section 4.
- Admin/multi-user visibility inside the floating widget (§5) — it always shows only the logged-in user's own events, even for users who can see other staff's events on the full My Calendar page.
- A permission flag to control who sees the floating My Calendar button — it is visible to every authenticated user, unlike the permission-gated Chats toggle.
- Any new filter UI (participant/user/shift-type/event-type) inside the floating widget itself — filtering only exists on the full My Calendar page.

## Testing Plan

- **Unit**: `CalendarRecurrenceGenerator` — occurrence dates for each frequency/interval/weekday combination, including month-end edge cases (e.g. "monthly on the 31st" against February) and `after_count`/`on_date` cutoffs.
- **Unit**: `CalendarRecurrenceScopeService` — each of `single`/`following`/`all` for both edit and delete, verifying row counts, `is_recurrence_exception` flags, and series-split correctness (old series capped, new series created with the right starting rule).
- **Feature**: `myCalendarStore` creating a repeating event end-to-end (series row + N occurrence rows), editing with each scope, deleting with each scope.
- **Feature**: `myCalendarData` with `event_type[]` covering all-checked (today's default), a subset, and empty-array (zero results).
- **Feature**: `CalendarEventReminder` command against a materialized recurring occurrence, confirming no changes were needed there (regression check that recurrence is invisible to the reminder cron, as designed in §1).
- **Feature**: `myCalendarWidgetData` returns only the requesting user's own events for a given week, correctly grouped by date, and excludes other users' events even for an admin-permission user.
- **Manual**: visual check of wrapped event boxes and the "+more" popover on a day with 5+ events across dayGridMonth/timeGridWeek/timeGridDay/listMonth views.
- **Manual**: floating widget opens/closes via the toolbar toggle below Chats, week navigation (`<`/`>`, Today, date-picker jump) updates the day strip and agenda list, "+" and event clicks open the correct existing modals, and "View full calendar" navigates to `/my_calendar`.
