diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d65055..5fb087a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added an Agenda view to the calendar - a chronological, date-grouped list of upcoming events and scheduled tasks, selectable alongside Day/Week/Month/Year (#88) - Create a multi-day all-day event by dragging the mouse across multiple days on the all-day row, like Google Calendar; the New Event modal opens pre-filled as an all-day event spanning the selected days (#79) - Added `.github/copilot-instructions.md` with repository-wide guidance for GitHub Copilot's coding agent (setup, commands, architecture, SAAS/open-source separation, code-style conventions) - Show the application version in a footer on every page, linking to the project's GitHub page (#111) diff --git a/openspec/changes/archive/2026-06-22-issue-88-agenda-view/.openspec.yaml b/openspec/changes/archive/2026-06-22-issue-88-agenda-view/.openspec.yaml new file mode 100644 index 00000000..38f76288 --- /dev/null +++ b/openspec/changes/archive/2026-06-22-issue-88-agenda-view/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-22 diff --git a/openspec/changes/archive/2026-06-22-issue-88-agenda-view/design.md b/openspec/changes/archive/2026-06-22-issue-88-agenda-view/design.md new file mode 100644 index 00000000..a100bf60 --- /dev/null +++ b/openspec/changes/archive/2026-06-22-issue-88-agenda-view/design.md @@ -0,0 +1,52 @@ +## Context + +The calendar header (`src/components/calendar/Calendar.tsx`) switches between view components (`DayView`, `WeekView`, `MonthView`, `MultiMonthView`) based on the `view` value from `useViewStore` (`src/store/calendar.ts`). Each view component is a thin wrapper around a `FullCalendar` instance that: +- reads merged events+tasks via `useCalendarStore().getAllCalendarItems(start, end)`, +- filters to enabled feeds (and always-included `feedId === "tasks"`), +- formats each item into FullCalendar's event shape (color from feed/task, `classNames`, `extendedProps`), +- opens an `EventQuickView` popover on click. + +The `CalendarView` type already includes `"agenda"` (`src/types/calendar.ts:83`), but `Calendar.tsx` only handles `day`/`week`/`month` and defaults everything else (including `agenda`) to `MultiMonthView`. + +## Goals / Non-Goals + +Goals: +- A selectable Agenda view that lists upcoming events + scheduled tasks chronologically, grouped by day, reusing existing data and filtering. +- Match existing conventions: time format from user settings, feed-enabled filtering, click → quick view, week-start day. + +Non-Goals (kept out to limit blast radius; the issue/image do not require them): +- No inline "Add task" button or "Refresh all tasks" footer from the Motion screenshot (those are separate features; the issue's core ask is the agenda list itself). +- No drag/drop or resize in the agenda (a list view is not a drag surface; FullCalendar's list view is read-oriented). +- No new persisted setting; reuse the existing localStorage-backed `view` state. + +## Decisions + +### Decision: Use FullCalendar `listPlugin` (`listWeek`) +FullCalendar already powers every other view and ships an official `@fullcalendar/list` plugin that renders exactly this UI (a date-grouped list with a "no events" empty state). Using it keeps the new view consistent with the others (same event objects, same `eventClick`, same time formatting) and avoids hand-rolling a list renderer. We pin it to the same `6.1.x` version as the other `@fullcalendar/*` packages already in `package.json`. + +- `initialView="listWeek"` so the agenda shows a rolling week, consistent with the Week view and with the header's week-based prev/next stepping. +- `listDayFormat` / `listDaySideFormat` and `eventTimeFormat` honor the user's 12h/24h `timeFormat` setting. +- `noEventsContent` provides the empty-state message. + +### Decision: Extract a pure formatting helper for testability +FullCalendar renders in a browser DOM and is awkward to unit-test in the Node/jsdom Jest env used here. To get real TDD coverage of the load-bearing logic, the item-shaping + feed-filtering logic lives in a pure function `formatAgendaItems(items, feeds)` in `src/lib/calendar-agenda.ts`, mirroring the `handleDatesSet` mapping the other views do inline. `AgendaView` calls this helper, and the unit tests assert on its output (filtering disabled feeds, always including tasks, color resolution, `extendedProps`, sort order). This is the same testing seam used by `src/lib/calendar-selection.ts` (issue #79). + +### Decision: Week-step navigation for the agenda +The header's `handlePrevWeek`/`handleNextWeek` step by month for `month`/`multiMonth` and by day/week otherwise. The agenda spans a week (`listWeek`), so it falls into the existing "else" (7-day) branch automatically - we confirm `agenda` is not routed into the month-stepping branch. No new navigation code needed beyond ensuring the branch condition stays correct. + +### Decision: Open-source, no SAAS gating +This is a core UI affordance with no premium dimension, so it ships in the shared (non-`.saas`/`.open`) component path like the other views. + +## Risks / Trade-offs + +- **New dependency**: adds `@fullcalendar/list`. Mitigation: it is a first-party FullCalendar package at the same version as packages already present; minimal supply-chain risk and no transitive bloat beyond the FullCalendar core already installed. +- **List view is read-oriented**: no drag/drop/resize. Acceptable - the feature request is a read/scan view; editing remains available by clicking an item (quick view → edit), consistent with how a click works in other views. +- **Quick-view positioning**: the agenda reuses `EventQuickView` anchored to the clicked element, exactly as the other views do, so behavior is consistent. + +## Migration Plan + +None. Additive UI only - no data migration, no schema change. Existing persisted `view` values are unaffected; a user whose stored view was already `"agenda"` (previously rendering the Year view) will now correctly see the agenda. + +## Open Questions + +None blocking. The Motion screenshot's extra chrome ("Add task", "Refresh all tasks", per-task checkboxes) is intentionally out of scope for this change and can be layered on later as separate enhancements. diff --git a/openspec/changes/archive/2026-06-22-issue-88-agenda-view/proposal.md b/openspec/changes/archive/2026-06-22-issue-88-agenda-view/proposal.md new file mode 100644 index 00000000..3a251003 --- /dev/null +++ b/openspec/changes/archive/2026-06-22-issue-88-agenda-view/proposal.md @@ -0,0 +1,25 @@ +## Why + +FluidCalendar offers Day, Week, Month, and Year views, but no way to see what is coming up as a single chronological list (issue #88). The requested "Agenda View" (see the Motion reference image in the issue) is a date-grouped, scrollable list of upcoming events and scheduled tasks - useful for quickly scanning the day/week ahead without scanning a time grid. The `"agenda"` value is already declared in the `CalendarView` type union but no view renders it; selecting it today falls through to the Year view. + +## What Changes + +- Add an `AgendaView` calendar view that renders calendar events and scheduled tasks as a chronological, date-grouped list using FullCalendar's `listPlugin` (`listWeek`), matching the data, feed-filtering, click-to-quick-view, and time-format behavior of the existing Day/Week/Month views. +- Add an **Agenda** button to the calendar header view switcher, alongside Day / Week / Month / Year. +- Wire the existing `"agenda"` `CalendarView` value through `Calendar.tsx` so it renders `AgendaView` instead of falling through to the Year (`multiMonth`) view. +- Make the header prev/next navigation step by a week when the Agenda view is active (the agenda spans a week, like the Week view). +- Show an empty-state message when the agenda range has no events or tasks. + +## Capabilities + +### New Capabilities +- `calendar-agenda-view`: A chronological, date-grouped list view of upcoming calendar events and scheduled tasks, selectable from the calendar header. + +### Modified Capabilities + + +## Impact + +- Code: new `src/components/calendar/AgendaView.tsx`; `src/components/calendar/Calendar.tsx` (add the Agenda button + render branch + week-stepping for agenda); `package.json` (add `@fullcalendar/list` at the same 6.1.x version as the other FullCalendar packages); `CHANGELOG.md`. +- New helper `src/lib/calendar-agenda.ts` (with unit tests) for the pure formatting/filtering logic the view uses, so the behavior is testable without rendering FullCalendar. +- No API, schema, store, or SAAS-gating changes. The view reuses the existing `getAllCalendarItems()` data source and feed-enabled filtering. Open-source feature (no premium gating). diff --git a/openspec/changes/archive/2026-06-22-issue-88-agenda-view/specs/calendar-agenda-view/spec.md b/openspec/changes/archive/2026-06-22-issue-88-agenda-view/specs/calendar-agenda-view/spec.md new file mode 100644 index 00000000..b8c88f4c --- /dev/null +++ b/openspec/changes/archive/2026-06-22-issue-88-agenda-view/specs/calendar-agenda-view/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Agenda view lists upcoming events and tasks chronologically + +The calendar SHALL provide an "Agenda" view that displays calendar events and scheduled tasks as a chronological, date-grouped list for a rolling one-week range. The Agenda view SHALL be selectable from the calendar header view switcher alongside Day, Week, Month, and Year, and selecting it SHALL render the agenda list (not the Year view). The list SHALL include the same items the other views show for the active range: events from enabled feeds plus scheduled tasks, with each task or event opening its quick view on click. + +#### Scenario: Selecting the Agenda view +- **WHEN** the user clicks the "Agenda" button in the calendar header +- **THEN** the calendar content area shows a chronological, date-grouped list of events and scheduled tasks for the active week (not the Year/multi-month grid) + +#### Scenario: Agenda shows enabled-feed events and tasks +- **WHEN** the Agenda view is active for a range that contains events from an enabled feed and one or more scheduled tasks +- **THEN** those events and tasks appear in the list, sorted by start time, each labeled with its time and colored by its feed or task color + +#### Scenario: Disabled feeds are excluded +- **WHEN** a feed is disabled +- **THEN** that feed's events do NOT appear in the Agenda list, while scheduled tasks (feed id `tasks`) continue to appear + +#### Scenario: Empty range shows an empty state +- **WHEN** the Agenda view is active for a range with no events and no scheduled tasks +- **THEN** the list shows an empty-state message rather than an empty/blank area + +#### Scenario: Time format follows user settings +- **WHEN** the user's time-format setting is 12-hour (or 24-hour) +- **THEN** the times shown in the Agenda list are rendered in that format + +#### Scenario: Navigation steps by week +- **WHEN** the Agenda view is active and the user clicks the header next/previous navigation +- **THEN** the agenda range advances or retreats by one week (consistent with the Week view), not by a month + +#### Scenario: Deleting a recurring occurrence does not delete the whole series +- **WHEN** the user deletes a recurring event occurrence from the Agenda quick view that is an expanded instance (not the recurring master) +- **THEN** only that occurrence is deleted (delete mode `single`), and the rest of the series is left intact; only deleting the recurring master deletes the series + +#### Scenario: The global "new event" command works while the Agenda view is active +- **WHEN** the user triggers the calendar create-event command (or shortcut) while the Agenda view is active +- **THEN** the New Event modal opens (the Agenda view honors the shared event-modal store, like the other calendar views), rather than the command silently doing nothing diff --git a/openspec/changes/archive/2026-06-22-issue-88-agenda-view/tasks.md b/openspec/changes/archive/2026-06-22-issue-88-agenda-view/tasks.md new file mode 100644 index 00000000..6e93dee7 --- /dev/null +++ b/openspec/changes/archive/2026-06-22-issue-88-agenda-view/tasks.md @@ -0,0 +1,22 @@ +## 1. Pure agenda-formatting helper (TDD) + +- [x] 1.1 Write failing unit tests in `src/__tests__/calendar-agenda.test.ts` for `formatAgendaItems(items, feeds)` covering: includes items whose `feedId === "tasks"` even when no matching feed; excludes items whose feed is disabled or missing; includes items whose feed is enabled; resolves background/border color from task color (tasks) or feed color (events) with sensible fallbacks; preserves `extendedProps` (isTask, status, priority, isRecurring); tags task/event classNames; sorts the result by start time ascending +- [x] 1.2 Implement `src/lib/calendar-agenda.ts` with `formatAgendaItems` to make the tests pass (mirroring the existing `handleDatesSet` mapping used by the other views) + +## 2. AgendaView component + +- [x] 2.1 Add `@fullcalendar/list` to `package.json` at the same `6.1.x` version as the other `@fullcalendar/*` packages +- [x] 2.2 Create `src/components/calendar/AgendaView.tsx` using `listPlugin` (`initialView="listWeek"`), reusing `getAllCalendarItems` + `formatAgendaItems`, feed/task data loading, `EventQuickView` on click, `noEventsContent` empty state, and time formats from user settings (mirroring `DayView.tsx` patterns) +- [x] 2.3 Add `resolveEventDeleteMode` (TDD) so deleting a recurring occurrence from the agenda quick view deletes only that occurrence (`single`) and never escalates an expanded instance to a whole-series delete; only the recurring master deletes as `series` (fixes Codex review finding) +- [x] 2.4 Wire the shared `useEventModalStore` into AgendaView (open state + defaultDate/defaultEndDate + clear on close) so the global `calendar.new-event` command/shortcut opens the New Event modal while the agenda is active, matching Day/Week/Month/Year (fixes Codex review finding) + +## 3. Wire into the calendar header + +- [x] 3.1 Add an "Agenda" button to the view switcher in `src/components/calendar/Calendar.tsx` +- [x] 3.2 Add a render branch so `view === "agenda"` renders `AgendaView` (not the `multiMonth` fallthrough) +- [x] 3.3 Confirm prev/next header navigation steps by week (not month) when the agenda view is active (agenda falls into the existing 7-day `else` branch in `handlePrev/NextWeek`) + +## 4. Verify + document + +- [x] 4.1 Local gate green: `npm run test:unit` (new `calendar-agenda` suite passes; 2 pre-existing unrelated `google-*` suites fail identically on `origin/main`), `npm run type-check` clean, `npm run lint` clean +- [x] 4.2 Add a `CHANGELOG.md` entry under `[unreleased]` diff --git a/openspec/specs/calendar-agenda-view/spec.md b/openspec/specs/calendar-agenda-view/spec.md new file mode 100644 index 00000000..82afd6e6 --- /dev/null +++ b/openspec/specs/calendar-agenda-view/spec.md @@ -0,0 +1,41 @@ +# calendar-agenda-view Specification + +## Purpose +TBD - created by archiving change issue-88-agenda-view. Update Purpose after archive. +## Requirements +### Requirement: Agenda view lists upcoming events and tasks chronologically + +The calendar SHALL provide an "Agenda" view that displays calendar events and scheduled tasks as a chronological, date-grouped list for a rolling one-week range. The Agenda view SHALL be selectable from the calendar header view switcher alongside Day, Week, Month, and Year, and selecting it SHALL render the agenda list (not the Year view). The list SHALL include the same items the other views show for the active range: events from enabled feeds plus scheduled tasks, with each task or event opening its quick view on click. + +#### Scenario: Selecting the Agenda view +- **WHEN** the user clicks the "Agenda" button in the calendar header +- **THEN** the calendar content area shows a chronological, date-grouped list of events and scheduled tasks for the active week (not the Year/multi-month grid) + +#### Scenario: Agenda shows enabled-feed events and tasks +- **WHEN** the Agenda view is active for a range that contains events from an enabled feed and one or more scheduled tasks +- **THEN** those events and tasks appear in the list, sorted by start time, each labeled with its time and colored by its feed or task color + +#### Scenario: Disabled feeds are excluded +- **WHEN** a feed is disabled +- **THEN** that feed's events do NOT appear in the Agenda list, while scheduled tasks (feed id `tasks`) continue to appear + +#### Scenario: Empty range shows an empty state +- **WHEN** the Agenda view is active for a range with no events and no scheduled tasks +- **THEN** the list shows an empty-state message rather than an empty/blank area + +#### Scenario: Time format follows user settings +- **WHEN** the user's time-format setting is 12-hour (or 24-hour) +- **THEN** the times shown in the Agenda list are rendered in that format + +#### Scenario: Navigation steps by week +- **WHEN** the Agenda view is active and the user clicks the header next/previous navigation +- **THEN** the agenda range advances or retreats by one week (consistent with the Week view), not by a month + +#### Scenario: Deleting a recurring occurrence does not delete the whole series +- **WHEN** the user deletes a recurring event occurrence from the Agenda quick view that is an expanded instance (not the recurring master) +- **THEN** only that occurrence is deleted (delete mode `single`), and the rest of the series is left intact; only deleting the recurring master deletes the series + +#### Scenario: The global "new event" command works while the Agenda view is active +- **WHEN** the user triggers the calendar create-event command (or shortcut) while the Agenda view is active +- **THEN** the New Event modal opens (the Agenda view honors the shared event-modal store, like the other calendar views), rather than the command silently doing nothing + diff --git a/package-lock.json b/package-lock.json index a0f51704..73bfaa7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@fullcalendar/core": "^6.1.15", "@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/list": "^6.1.15", "@fullcalendar/multimonth": "^6.1.15", "@fullcalendar/react": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15", @@ -1580,6 +1581,15 @@ "@fullcalendar/core": "~6.1.15" } }, + "node_modules/@fullcalendar/list": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.15.tgz", + "integrity": "sha512-U1bce04tYDwkFnuVImJSy2XalYIIQr6YusOWRPM/5ivHcJh67Gm8CIMSWpi3KdRSNKFkqBxLPkfZGBMaOcJYug==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, "node_modules/@fullcalendar/multimonth": { "version": "6.1.15", "resolved": "https://registry.npmjs.org/@fullcalendar/multimonth/-/multimonth-6.1.15.tgz", diff --git a/package.json b/package.json index 51e71ad8..847999b1 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@fullcalendar/core": "^6.1.15", "@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/list": "^6.1.15", "@fullcalendar/multimonth": "^6.1.15", "@fullcalendar/react": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15", diff --git a/src/__tests__/calendar-agenda.test.ts b/src/__tests__/calendar-agenda.test.ts new file mode 100644 index 00000000..2a77cf8c --- /dev/null +++ b/src/__tests__/calendar-agenda.test.ts @@ -0,0 +1,195 @@ +import { formatAgendaItems, resolveEventDeleteMode } from "@/lib/calendar-agenda"; + +import { CalendarEvent, CalendarFeed } from "@/types/calendar"; + +// Minimal CalendarEvent factory for the agenda formatter tests. The formatter +// only reads a subset of fields, so we cast a partial through unknown. +function makeItem(overrides: Partial): CalendarEvent { + return { + id: "evt-1", + feedId: "feed-1", + title: "Event", + start: new Date(2026, 5, 10, 9, 0, 0), + end: new Date(2026, 5, 10, 10, 0, 0), + isRecurring: false, + isMaster: false, + allDay: false, + ...overrides, + } as CalendarEvent; +} + +function makeFeed(overrides: Partial): CalendarFeed { + return { + id: "feed-1", + name: "Feed", + type: "GOOGLE", + color: "#abcdef", + enabled: true, + ...overrides, + } as CalendarFeed; +} + +describe("formatAgendaItems", () => { + it("includes task items (feedId 'tasks') even when no matching feed exists", () => { + const task = makeItem({ + id: "task-1", + feedId: "tasks", + title: "Do the thing", + color: "#112233", + extendedProps: { isTask: true, status: "TODO", priority: "high" }, + }); + + const result = formatAgendaItems([task], []); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("task-1"); + expect(result[0].extendedProps?.isTask).toBe(true); + }); + + it("excludes events whose feed is disabled", () => { + const event = makeItem({ feedId: "feed-1" }); + const disabledFeed = makeFeed({ id: "feed-1", enabled: false }); + + const result = formatAgendaItems([event], [disabledFeed]); + + expect(result).toHaveLength(0); + }); + + it("excludes events whose feed is missing entirely", () => { + const event = makeItem({ feedId: "ghost-feed" }); + + const result = formatAgendaItems([event], []); + + expect(result).toHaveLength(0); + }); + + it("includes events whose feed is enabled", () => { + const event = makeItem({ feedId: "feed-1" }); + const enabledFeed = makeFeed({ id: "feed-1", enabled: true }); + + const result = formatAgendaItems([event], [enabledFeed]); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("evt-1"); + }); + + it("resolves event color from its feed, falling back to the default event color", () => { + const colored = makeItem({ id: "a", feedId: "feed-1" }); + const colorless = makeItem({ id: "b", feedId: "feed-2" }); + const feeds = [ + makeFeed({ id: "feed-1", color: "#abcdef", enabled: true }), + makeFeed({ id: "feed-2", color: undefined, enabled: true }), + ]; + + const result = formatAgendaItems([colored, colorless], feeds); + const a = result.find((r) => r.id === "a")!; + const b = result.find((r) => r.id === "b")!; + + expect(a.backgroundColor).toBe("#abcdef"); + expect(a.borderColor).toBe("#abcdef"); + expect(b.backgroundColor).toBe("#3b82f6"); + expect(b.borderColor).toBe("#3b82f6"); + }); + + it("resolves task color from the item color, falling back to the default task color", () => { + const colored = makeItem({ + id: "t1", + feedId: "tasks", + color: "#112233", + extendedProps: { isTask: true }, + }); + const colorless = makeItem({ + id: "t2", + feedId: "tasks", + color: undefined, + extendedProps: { isTask: true }, + }); + + const result = formatAgendaItems([colored, colorless], []); + const t1 = result.find((r) => r.id === "t1")!; + const t2 = result.find((r) => r.id === "t2")!; + + expect(t1.backgroundColor).toBe("#112233"); + expect(t2.backgroundColor).toBe("#4f46e5"); + }); + + it("preserves extendedProps (isTask, status, priority, isRecurring)", () => { + const task = makeItem({ + id: "task-1", + feedId: "tasks", + isRecurring: true, + extendedProps: { + isTask: true, + status: "IN_PROGRESS", + priority: "high", + }, + }); + + const result = formatAgendaItems([task], []); + + expect(result[0].extendedProps?.isTask).toBe(true); + expect(result[0].extendedProps?.status).toBe("IN_PROGRESS"); + expect(result[0].extendedProps?.priority).toBe("high"); + expect(result[0].extendedProps?.isRecurring).toBe(true); + }); + + it("tags task items with the calendar-task class and events with calendar-event", () => { + const task = makeItem({ + id: "t", + feedId: "tasks", + extendedProps: { isTask: true }, + }); + const event = makeItem({ id: "e", feedId: "feed-1" }); + const feeds = [makeFeed({ id: "feed-1", enabled: true })]; + + const result = formatAgendaItems([task, event], feeds); + const t = result.find((r) => r.id === "t")!; + const e = result.find((r) => r.id === "e")!; + + expect(t.classNames).toContain("calendar-task"); + expect(e.classNames).toContain("calendar-event"); + }); + + it("sorts the result by start time ascending", () => { + const feeds = [makeFeed({ id: "feed-1", enabled: true })]; + const later = makeItem({ + id: "later", + feedId: "feed-1", + start: new Date(2026, 5, 10, 15, 0, 0), + end: new Date(2026, 5, 10, 16, 0, 0), + }); + const earlier = makeItem({ + id: "earlier", + feedId: "feed-1", + start: new Date(2026, 5, 10, 8, 0, 0), + end: new Date(2026, 5, 10, 9, 0, 0), + }); + + const result = formatAgendaItems([later, earlier], feeds); + + expect(result.map((r) => r.id)).toEqual(["earlier", "later"]); + }); +}); + +describe("resolveEventDeleteMode", () => { + it("returns 'single' for a non-recurring event", () => { + const event = makeItem({ isRecurring: false }); + expect(resolveEventDeleteMode(event)).toBe("single"); + }); + + it("returns 'single' for a recurring occurrence (instance row, not the master)", () => { + // An expanded occurrence is recurring but is NOT the master; deleting it + // must remove only that occurrence, never the whole series. + const occurrence = makeItem({ + isRecurring: true, + isMaster: false, + masterEventId: "master-1", + }); + expect(resolveEventDeleteMode(occurrence)).toBe("single"); + }); + + it("returns 'series' only for the recurring master event", () => { + const master = makeItem({ isRecurring: true, isMaster: true }); + expect(resolveEventDeleteMode(master)).toBe("series"); + }); +}); diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx new file mode 100644 index 00000000..caa65cf1 --- /dev/null +++ b/src/components/calendar/AgendaView.tsx @@ -0,0 +1,266 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { DatesSetArg, EventClickArg } from "@fullcalendar/core"; +import interactionPlugin from "@fullcalendar/interaction"; +import listPlugin from "@fullcalendar/list"; +import FullCalendar from "@fullcalendar/react"; + +import { TaskModal } from "@/components/tasks/TaskModal"; + +import { + AgendaEvent, + formatAgendaItems, + resolveEventDeleteMode, +} from "@/lib/calendar-agenda"; +import { useEventModalStore } from "@/lib/commands/groups/calendar"; + +import { useCalendarStore } from "@/store/calendar"; +import { useSettingsStore } from "@/store/settings"; +import { useTaskStore } from "@/store/task"; + +import { CalendarEvent } from "@/types/calendar"; +import { Task, TaskStatus } from "@/types/task"; + +import { CalendarEventContent } from "./CalendarEventContent"; +import { EventModal } from "./EventModal"; +import { EventQuickView } from "./EventQuickView"; + +interface AgendaViewProps { + currentDate: Date; +} + +export function AgendaView({ currentDate }: AgendaViewProps) { + const { feeds, getAllCalendarItems, isLoading, removeEvent } = + useCalendarStore(); + const { user: userSettings } = useSettingsStore(); + const { updateTask } = useTaskStore(); + const [selectedEvent, setSelectedEvent] = useState>(); + const [selectedTask, setSelectedTask] = useState(); + const [isEventModalOpen, setIsEventModalOpen] = useState(false); + const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); + const [events, setEvents] = useState([]); + const calendarRef = useRef(null); + const tasks = useTaskStore((state) => state.tasks); + const [quickViewItem, setQuickViewItem] = useState(); + const [isTask, setIsTask] = useState(false); + const eventModalStore = useEventModalStore(); + const [clickedElement, setClickedElement] = useState(null); + + // Update events when the calendar range changes + const handleDatesSet = useCallback( + (arg: DatesSetArg) => { + const items = getAllCalendarItems(arg.start, arg.end); + setEvents(formatAgendaItems(items, feeds)); + }, + [feeds, getAllCalendarItems] + ); + + // Initial data load (only if the store is empty - the parent may have + // already loaded data from the server) + useEffect(() => { + const state = useCalendarStore.getState(); + const taskState = useTaskStore.getState(); + + if (state.events.length === 0 || state.feeds.length === 0) { + state.loadFromDatabase(); + } + + if (taskState.tasks.length === 0) { + taskState.fetchTasks(); + } + }, []); + + // Re-format items when loading state changes, feeds change, or tasks change + useEffect(() => { + if (!isLoading && calendarRef.current) { + const calendar = calendarRef.current.getApi(); + handleDatesSet({ + start: calendar.view.activeStart, + end: calendar.view.activeEnd, + startStr: calendar.view.activeStart.toISOString(), + endStr: calendar.view.activeEnd.toISOString(), + timeZone: userSettings.timeZone, + view: calendar.view, + }); + } + }, [isLoading, feeds, userSettings.timeZone, handleDatesSet, tasks]); + + // Update calendar date when currentDate changes + useEffect(() => { + if (calendarRef.current) { + setTimeout(() => { + if (calendarRef.current) { + const calendar = calendarRef.current.getApi(); + calendar.gotoDate(currentDate); + } + }, 0); + } + }, [currentDate]); + + const handleEventClick = (info: EventClickArg) => { + const item = info.event.extendedProps; + const itemId = info.event.id; + const isTaskItem = item.isTask; + + // Store the clicked element for positioning the quick view popover + setClickedElement(info.el); + + if (isTaskItem) { + const task = useTaskStore.getState().tasks.find((t) => t.id === itemId); + if (task) { + setQuickViewItem(task); + setIsTask(true); + } + } else { + const event = useCalendarStore + .getState() + .events.find((e) => e.id === itemId); + setQuickViewItem(event as CalendarEvent); + setIsTask(false); + } + }; + + const handleEventModalClose = () => { + setIsEventModalOpen(false); + eventModalStore.setOpen(false); + setSelectedEvent(undefined); + eventModalStore.setDefaultDate(undefined); + eventModalStore.setDefaultEndDate(undefined); + }; + + const handleTaskModalClose = () => { + setIsTaskModalOpen(false); + setSelectedTask(undefined); + }; + + const handleQuickViewClose = () => { + setQuickViewItem(undefined); + setClickedElement(null); + }; + + const handleQuickViewEdit = () => { + if (!quickViewItem) return; + + if (isTask) { + setSelectedTask(quickViewItem as Task); + setIsTaskModalOpen(true); + } else { + setSelectedEvent(quickViewItem as CalendarEvent); + setIsEventModalOpen(true); + } + handleQuickViewClose(); + }; + + const handleQuickViewDelete = async () => { + if (!quickViewItem) return; + + if (isTask) { + if (confirm("Are you sure you want to delete this task?")) { + await useTaskStore.getState().deleteTask(quickViewItem.id); + handleQuickViewClose(); + } + } else { + if (confirm("Are you sure you want to delete this event?")) { + // Delete only the clicked occurrence unless it is the recurring master; + // never escalate a single visible row to a whole-series delete. + await removeEvent( + quickViewItem.id, + resolveEventDeleteMode(quickViewItem as CalendarEvent) + ); + handleQuickViewClose(); + } + } + }; + + const handleQuickViewStatusChange = async ( + taskId: string, + status: TaskStatus + ) => { + if (!quickViewItem) return; + + await updateTask(taskId, { status }); + + // Update the quick view item to reflect the new status + if (isTask) { + const updatedTask = useTaskStore + .getState() + .tasks.find((t) => t.id === taskId); + if (updatedTask) { + setQuickViewItem(updatedTask); + } + } + }; + + return ( +
+ } + /> + + + + {selectedTask && ( + { + await updateTask(selectedTask.id, updates); + handleTaskModalClose(); + }} + onCreateTag={async (name: string, color?: string) => { + return useTaskStore.getState().createTag({ name, color }); + }} + /> + )} + + {quickViewItem && ( + + )} +
+ ); +} diff --git a/src/components/calendar/Calendar.tsx b/src/components/calendar/Calendar.tsx index 6fe61d3d..6702b1f0 100644 --- a/src/components/calendar/Calendar.tsx +++ b/src/components/calendar/Calendar.tsx @@ -6,6 +6,7 @@ import dynamic from "next/dynamic"; import { HiMenu } from "react-icons/hi"; import { IoChevronBack, IoChevronForward } from "react-icons/io5"; +import { AgendaView } from "@/components/calendar/AgendaView"; import { DayView } from "@/components/calendar/DayView"; import { FeedManager } from "@/components/calendar/FeedManager"; import { MonthView } from "@/components/calendar/MonthView"; @@ -216,6 +217,17 @@ export function Calendar({ > Year + @@ -227,6 +239,8 @@ export function Calendar({ ) : view === "month" ? ( + ) : view === "agenda" ? ( + ) : ( )} diff --git a/src/lib/calendar-agenda.ts b/src/lib/calendar-agenda.ts new file mode 100644 index 00000000..3bc4f81e --- /dev/null +++ b/src/lib/calendar-agenda.ts @@ -0,0 +1,96 @@ +import { getEventEditability } from "@/lib/calendar-drag"; + +import { CalendarEvent, CalendarFeed, ExtendedEventProps } from "@/types/calendar"; + +// Default colors mirror the other calendar views (see DayView/WeekView): tasks +// fall back to indigo, events to blue. +const DEFAULT_TASK_EVENT_COLOR = "#4f46e5"; +const DEFAULT_EVENT_COLOR = "#3b82f6"; + +// The FullCalendar-event shape the agenda view feeds into ``. +// Kept structurally identical to what the Day/Week/Month views build inline so +// the list renders items consistently with the grid views. +export interface AgendaEvent { + id: string; + title: string; + start: Date; + end: Date; + location?: string; + backgroundColor: string; + borderColor: string; + allDay: boolean; + classNames: string[]; + startEditable: boolean; + durationEditable: boolean; + extendedProps: ExtendedEventProps & { + isRecurring?: boolean; + }; +} + +/** + * Decide the delete mode for an event deleted from the agenda quick view. + * + * Deleting a single visible row must never escalate to wiping the whole + * recurring series: an expanded occurrence is `isRecurring: true` but is not + * the master, so it must delete as `"single"`. Only the recurring master + * (`isMaster: true`) deletes as `"series"`. Non-recurring events delete as + * `"single"`. + */ +export function resolveEventDeleteMode( + event: Pick +): "single" | "series" { + return event.isRecurring && event.isMaster ? "series" : "single"; +} + +/** + * Format the merged calendar items (events + tasks-as-events from + * `getAllCalendarItems`) into the FullCalendar event shape the Agenda view + * renders, applying the same enabled-feed filtering the other views use and + * sorting the result chronologically. + * + * Tasks (feedId === "tasks") are always included; events are included only when + * their feed exists and is enabled. The logic mirrors the inline mapping in + * `DayView`/`WeekView` so the agenda is consistent with the grid views, but is + * pulled out as a pure function so it can be unit-tested without rendering + * FullCalendar. + */ +export function formatAgendaItems( + items: CalendarEvent[], + feeds: CalendarFeed[] +): AgendaEvent[] { + return items + .filter((item) => { + if (item.feedId === "tasks") return true; + const feed = feeds.find((f) => f.id === item.feedId); + return Boolean(feed?.enabled); + }) + .map((item) => { + const isTask = item.feedId === "tasks"; + const color = isTask + ? item.color || DEFAULT_TASK_EVENT_COLOR + : feeds.find((f) => f.id === item.feedId)?.color || DEFAULT_EVENT_COLOR; + + return { + id: item.id, + title: item.title, + start: item.start, + end: item.end, + location: item.location, + backgroundColor: color, + borderColor: color, + allDay: item.allDay, + classNames: [ + item.extendedProps?.isTask ? "calendar-task" : "calendar-event", + ], + ...getEventEditability(item, feeds), + extendedProps: { + ...item.extendedProps, + isTask: item.extendedProps?.isTask, + isRecurring: item.isRecurring, + status: item.extendedProps?.status, + priority: item.extendedProps?.priority, + }, + }; + }) + .sort((a, b) => a.start.getTime() - b.start.getTime()); +}