# Calendar ↔ Task Creation — Implementation Plan

**Spec:** docs/specs/2026-05-31-calendar-task-creation.md  ·  **Slug:** calendar-task-creation  ·  **Wave:** 9
**Depends on:** calendar-event-detail, calendar-module, foundation-auth-rbac, tasks-board-engine

## Goal
Deliver the bidirectional bridge between the calendar and the task board. From a manual calendar event the user can spin up a task pre-filled from the event (Flow 1); from a task the user can place a personal time-block on the calendar (Flow 2). The plan also surfaces a collapsible "Upcoming tasks" sidebar on `/calendar` driven by the existing `GET /api/tasks` date-range filter. No new tables are introduced — every persistence path reuses upstream query helpers (`createTask`, `createCalendarEvent`, `listTasks`).

## Architecture
Two new thin API routes plus three UI surfaces, all composing existing primitives:

- **Flow 1 — task from event.** New route `POST /api/calendar/events/:id/tasks`. Loads the source event with `getCalendarEvent(db, ctx, id)` (from `calendar-event-detail`), then calls `createTask(db, tenantId, input)` (from `tasks-board-engine`). The event is used only to pre-fill defaults (its `start_at` date → `due_date`, its `project_id`, its `lead_id`). The created task is **not** linked back to the event. `due_date` is set to the event start date; `project_id` defaults to the event's `project_id` when the body omits it.
- **Flow 2 — time-block from task.** New route `POST /api/tasks/:id/calendar-block`. Loads the task with `getTask(db, tenantId, id)`, then calls `createCalendarEvent(db, ctx, input)` with `source='manual'`, `title = "Work on: {task.title}"`, `project_id = task.project_id`, and **no `task_id`** (linking would make the event read-only per spec 102 / `calendar-event-detail`).
- **Sidebar + projection.** The "Upcoming tasks (next 7 days)" panel reads `GET /api/tasks` with `due_from`/`due_to` (mapped to `TaskFilters.dueFrom`/`dueTo`) and an "open" (non-terminal) status constraint. Inline checkbox completion calls `PATCH /api/tasks/:id`. Task due-date projection onto the grid is owned by `calendar-module` and is unchanged here.

Upstream tables consumed (read/write via helpers only — no raw Drizzle from routes, `no-raw-drizzle-from-routes`):
`calendar_events` (id, tenant_id, created_by, title, start_at, end_at, source, task_id, project_id, customer_id, lead_id), `tasks` (id, tenant_id, project_id, status_id, title, assignee_id, reporter_id, due_date, source), `task_statuses` (id, tenant_id, project_id, is_terminal).

Upstream exports consumed by name: `createTask`, `getTask`, `listTasks`, `CreateTaskInput`, `TaskFilters`, `TaskObject`, `serializeTask`, `createCalendarEvent`, `getCalendarEvent`, `CreateCalendarEventInput`, `serializeCalendarEvent`, `authMiddleware`, `requirePermission`, `requireModuleEnabled`, `tenantQuery`, `ApiError`.

### `tasks.lead_id` note (Constraint — read before coding)
The spec's Flow 1 says "Sets `tasks.lead_id = event.lead_id` if present (spec 100 schema delta)." Spec 100 is `lead-to-proposal-flow`, which is **not** a dependency of this task, and the `tasks` table defined by `tasks-board-engine` has **no `lead_id` column**. Therefore this plan **does not** emit DDL for `tasks.lead_id` and **does not** add it to `CreateTaskInput`. The lead passthrough is implemented as a **conditional, best-effort field**: the create-task route forwards `event.lead_id` into the task-create input **only if** `CreateTaskInput` already exposes a `leadId` field at build time (i.e. spec 100 has shipped). If that field does not exist, the route silently omits it. Under no circumstance does the implementer add a `lead_id` column or migration in this task.

## Tech Stack
- **API (Hono, `apps/zync-api`):** two new route modules, mounted in `apps/zync-api/src/index.ts`. All routes run behind `authMiddleware`; Flow 1 also behind `requireModuleEnabled('tasks')`, Flow 2 behind `requireModuleEnabled('calendar')`. Zod validation (`require-zod-validation-in-routes`).
- **App UI (`apps/zync-app`, Vite+React):** inline "Create task" form inside the calendar event detail panel; "Block time on calendar" dialog launched from the task detail `…` menu; collapsible "Upcoming tasks" sidebar on `/calendar`. react-query for data; `@zync/ui` primitives (`Dialog`, `Sheet`, `Form`, `FormField`, `Input`, `Select`, `Button`, `Checkbox`, `toast`).
- **DB package (`packages/db`):** zero new tables/migrations; reuses existing query helpers only.
- **Cloudflare bindings:** none new (inherits the API Worker's Hyperdrive `DB` binding).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — API Flow 1 | 1 | `apps/zync-api/src/routes/calendar-task-bridge.ts`, `apps/zync-api/src/validation/calendar-task-bridge.ts`, `apps/zync-api/src/index.ts` | Tasks 1 & 2 parallel (separate route files) |
| A — API Flow 2 | 2 | `apps/zync-api/src/routes/calendar-task-bridge.ts` (same module), validation | with Task 1 |
| B — Sidebar data | 3 | (uses existing `GET /api/tasks`; verify filter mapping) | After A, parallel with C/D |
| C — UI Flow 1 | 4 | `apps/zync-app/src/features/calendar/CreateTaskFromEvent.tsx`, hooks | After A |
| C — UI Flow 2 | 5 | `apps/zync-app/src/features/tasks/BlockTimeOnCalendar.tsx`, hooks | After A, parallel with 4 |
| D — Sidebar UI | 6 | `apps/zync-app/src/features/calendar/UpcomingTasksSidebar.tsx`, hook | After 3, parallel with 4/5 |

## Tasks

### Task 1: API — `POST /api/calendar/events/:id/tasks` (create task from event)
**Blocks:** 4  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/routes/calendar-task-bridge.ts`
- Create: `apps/zync-api/src/validation/calendar-task-bridge.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router at `/api`)
**Steps:**
- [ ] Create a Hono sub-router; apply `authMiddleware` to the whole router.
- [ ] Define `createTaskFromEventSchema` (Zod) in the validation file (`require-zod-validation-in-routes`).
- [ ] Implement `POST /api/calendar/events/:id/tasks`, gated `requirePermission('tasks:write')` and `requireModuleEnabled('tasks')`.
- [ ] Load the source event with `getCalendarEvent(db, ctx, eventId)`; return `404 ApiError` if not found (tenant-scoped).
- [ ] Build `CreateTaskInput`: `title` (from body), `assigneeId = body.assignee_id ?? null`, `projectId = body.project_id ?? event.project_id ?? null`, `dueDate = body.due_date` (validated; default to the calendar date of `event.start_at` when body omits it), `reporterId = ctx.userId`, `source = 'manual'`. Do **not** set any `task_id`/event link on the task.
- [ ] Status resolution: the form supplies no status. If `CreateTaskInput` already resolves the tenant default status internally, pass nothing; otherwise resolve the tenant-global non-terminal status with the lowest `position` (`task_statuses` where `tenant_id = ctx.tenantId AND project_id IS NULL AND is_terminal = false ORDER BY position LIMIT 1`) via `tenantQuery` and pass its id as `statusId`. (Verify which by reading `createTask`'s signature before coding; pick exactly one.)
- [ ] Lead passthrough (conditional — see plan note): if `CreateTaskInput` exposes `leadId`, set `leadId = event.lead_id ?? undefined`; otherwise omit entirely. Never add a `lead_id` column or migration.
- [ ] Call `createTask(db, ctx.tenantId, input)`; respond `201` with `{ taskId: task.id }`.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/validation/calendar-task-bridge.ts
import { z } from 'zod';

export const createTaskFromEventSchema = z.object({
  title: z.string().min(1).max(500),
  assignee_id: z.string().uuid().optional(),
  project_id: z.string().uuid().optional(),
  due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD (DATE)
});
export type CreateTaskFromEventBody = z.infer<typeof createTaskFromEventSchema>;
```
```
POST /api/calendar/events/:id/tasks
  auth: authMiddleware
  module: requireModuleEnabled('tasks')
  perm:  requirePermission('tasks:write')
  body:  createTaskFromEventSchema  →  { taskId: string }   (201)
  errors: 404 event-not-found, 422 invalid body
```
**Acceptance:**
- [ ] Posting with a valid event id creates a `tasks` row whose `due_date` equals the body `due_date` (or the event's start date when omitted) and whose `project_id` falls back to `event.project_id`.
- [ ] The created task has **no** `task_id` / event back-link (event is inspiration, not artifact).
- [ ] No `lead_id` column or migration is introduced; the lead value is only forwarded when `CreateTaskInput.leadId` already exists.
- [ ] Caller lacking `tasks:write` → 403; tasks module disabled → degraded `requireModuleEnabled` response; cross-tenant event id → 404.

### Task 2: API — `POST /api/tasks/:id/calendar-block` (time-block from task)
**Blocks:** 5  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-api/src/routes/calendar-task-bridge.ts` (add endpoint to the same router)
- Modify: `apps/zync-api/src/validation/calendar-task-bridge.ts` (add schema)
**Steps:**
- [ ] Define `createCalendarBlockSchema` (Zod) with `date`, `start_time`, `end_time` (`HH:MM`).
- [ ] Implement `POST /api/tasks/:id/calendar-block`, gated `requirePermission('calendar:write')` and `requireModuleEnabled('calendar')`.
- [ ] Load the task with `getTask(db, ctx.tenantId, taskId)`; return `404 ApiError` if not found.
- [ ] Compose `start_at`/`end_at` as `TIMESTAMPTZ` by combining `date` + `start_time`/`end_time` in the tenant's `default_timezone` (`tenants.default_timezone`). Reject when `end_time <= start_time` with `422`.
- [ ] Build `CreateCalendarEventInput`: `title = \`Work on: ${task.title}\``, `start_at`, `end_at`, `source = 'manual'`, `projectId = task.project_id ?? null`, `createdBy = ctx.userId`. Set **no** `task_id` (omit it — keeps the block editable per spec 102).
- [ ] Call `createCalendarEvent(db, ctx, input)`; respond `201` with `{ eventId: event.id }`.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/validation/calendar-task-bridge.ts (added)
export const createCalendarBlockSchema = z.object({
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  start_time: z.string().regex(/^\d{2}:\d{2}$/),
  end_time: z.string().regex(/^\d{2}:\d{2}$/),
});
export type CreateCalendarBlockBody = z.infer<typeof createCalendarBlockSchema>;
```
```
POST /api/tasks/:id/calendar-block
  auth: authMiddleware
  module: requireModuleEnabled('calendar')
  perm:  requirePermission('calendar:write')
  body:  createCalendarBlockSchema  →  { eventId: string }  (201)
  errors: 404 task-not-found, 422 invalid body / end<=start
```
**Acceptance:**
- [ ] Created `calendar_events` row has `source = 'manual'`, `title = "Work on: {task.title}"`, `project_id = task.project_id`, and **`task_id` IS NULL** (explicit invariant — the block stays editable, never read-only).
- [ ] `end_time <= start_time` → 422; cross-tenant task id → 404; caller lacking `calendar:write` → 403; calendar module disabled → degraded response.

### Task 3: Verify `GET /api/tasks` date-range + open-status filter for the sidebar
**Blocks:** 6  ·  **Blocked by:** 1, 2
**Files:**
- Modify (only if the filter is missing): `apps/zync-api/src/routes/tasks.ts`, `apps/zync-api/src/validation/tasks.ts`, `packages/db/src/queries/tasks.ts`
**Steps:**
- [ ] Confirm `GET /api/tasks` already parses `due_from`/`due_to` into `TaskFilters.dueFrom`/`dueTo` (it does per `tasks-board-engine`); the sidebar's 7-day window needs no new backend if so.
- [ ] Resolve the "open" semantic: `?status=open` in the spec means **non-terminal** statuses, not a literal status id. Implement client-side by passing the tenant's non-terminal `status_id` list (fetched from `GET /api/tasks/statuses`) into the existing `status` filter, OR — preferred, to avoid an extra round-trip — add a boolean `open_only` query param that `listTasks` translates to `WHERE status_id IN (SELECT id FROM task_statuses WHERE tenant_id = $tenant AND is_terminal = false)`.
- [ ] If adding `open_only`: extend `taskFiltersSchema` with `open_only: z.coerce.boolean().optional()`, thread it into `TaskFilters` as `openOnly?: boolean`, and apply the non-terminal subquery in `listTasks` (via `tenantQuery`, `no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```ts
// packages/db/src/queries/tasks.ts — TaskFilters extension (only if open_only chosen)
export interface TaskFilters {
  project?: string; status?: string[]; priority?: string[]; assignee?: string;
  source?: string[]; dueFrom?: string; dueTo?: string; labels?: string[]; q?: string;
  openOnly?: boolean; // true → restrict to task_statuses.is_terminal = false
}
```
**Acceptance:**
- [ ] `GET /api/tasks?due_from=<today>&due_to=<today+7d>&open_only=true` returns only non-terminal tasks due within the window, tenant-scoped, paginated via `buildPaginated`.
- [ ] No terminal (DONE-type) task appears in the sidebar result.

### Task 4: UI — "Create task from event" inline form (Flow 1)
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/features/calendar/CreateTaskFromEvent.tsx`
- Create: `apps/zync-app/src/features/calendar/useCreateTaskFromEvent.ts`
- Modify: the calendar event detail panel component (owned by `calendar-event-detail`) to mount the `[+ Create task]` trigger for `source='manual'` events only
**Steps:**
- [ ] Add a `[+ Create task]` button in the event detail panel, rendered **only** when `event.source === 'manual'`.
- [ ] Clicking reveals an inline `Form` (not a modal) with fields: `Title` (`Input`, pre-empty), `Assignee` (`Select`, optional), `Project` (`Select`, optional, pre-filled with `event.project_id` when present), `Due` (`Input type=date`, pre-filled from the event's start date).
- [ ] Validate with a client mirror of `createTaskFromEventSchema`; submit via `useCreateTaskFromEvent` → `POST /api/calendar/events/:id/tasks`.
- [ ] On success: `toast` success, collapse the form, and invalidate the tasks list query key so the sidebar (Task 6) and `/tasks` refresh. Do **not** mutate the event.
- [ ] Pre-reduced-motion: any expand/collapse animation respects `prefers-reduced-motion` (no transform animation when reduced). Form controls carry `FormLabel`/`aria-label`; the inline form region has `role="form"` and an accessible name "New task from this event".
**Schema / Interfaces:**
```ts
// useCreateTaskFromEvent.ts
export function useCreateTaskFromEvent(eventId: string):
  UseMutationResult<{ taskId: string }, ApiError, CreateTaskFromEventBody>;
```
**Acceptance:**
- [ ] `[+ Create task]` appears only on manual events; task-sourced/external events do not show it.
- [ ] Due field is pre-filled from the event date; submitting creates the task and the event remains unchanged.
- [ ] Keyboard-only operation works; labels are associated; reduced-motion users see no animated expansion.

### Task 5: UI — "Block time on calendar" dialog (Flow 2)
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/features/tasks/BlockTimeOnCalendar.tsx`
- Create: `apps/zync-app/src/features/tasks/useCreateCalendarBlock.ts`
- Modify: the task detail `…` menu (component owned by `tasks-detail-communication`) to add a `[Block time on calendar]` item — reference it as an integration point, not owned here
**Steps:**
- [ ] Add a `[Block time on calendar]` item to the task detail `…` (`DropdownMenu`) menu; gate its visibility on the `calendar:write` permission and calendar module being enabled (`useModuleEnabled('calendar')`).
- [ ] Selecting opens a `Dialog` titled `Block time for: "{task.title}"` with: `Date` (`Input type=date`), `Start` + `End` (`Input type=time`), and a `Radio` group `Calendar: ● My calendar ○ Team calendar` (radio is presentational for now; both submit to the same endpoint — no team-calendar param exists in the API yet, so persist selection only if/when the API gains it; otherwise default to My calendar).
- [ ] Validate with a client mirror of `createCalendarBlockSchema` (including end > start); submit via `useCreateCalendarBlock` → `POST /api/tasks/:id/calendar-block`.
- [ ] On success: `toast` success, close dialog, invalidate the calendar events query key so the new block shows on `/calendar`.
- [ ] `Dialog` uses focus-trap + `aria-modal`; radio group has a group label; reduced-motion respected for the dialog transition.
**Schema / Interfaces:**
```ts
// useCreateCalendarBlock.ts
export function useCreateCalendarBlock(taskId: string):
  UseMutationResult<{ eventId: string }, ApiError, CreateCalendarBlockBody>;
```
**Acceptance:**
- [ ] The created calendar block appears on `/calendar` as a manual time-block and is fully editable (not read-only).
- [ ] `end_time <= start_time` is blocked client-side with an inline error and never reaches the server.
- [ ] Menu item hidden when the user lacks `calendar:write` or the calendar module is disabled.

### Task 6: UI — "Upcoming tasks" collapsible sidebar on `/calendar`
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/features/calendar/UpcomingTasksSidebar.tsx`
- Create: `apps/zync-app/src/features/calendar/useUpcomingTasks.ts`
- Modify: the `/calendar` page shell (owned by `calendar-module`) to mount the sidebar toggle
**Steps:**
- [ ] Add a sidebar toggle on `/calendar`, **collapsed by default**; persist the open/closed state in a UI store or `user_preferences` (do not force it open on every visit).
- [ ] When open, render a `Sheet`/panel "Upcoming tasks (next 7 days)" fed by `useUpcomingTasks()` → `GET /api/tasks?due_from=<today>&due_to=<today+7d>&open_only=true` (open/non-terminal semantic resolved per Task 3).
- [ ] Group results by relative day label (Today / Tomorrow / weekday); each row shows a completion `Checkbox`, the task title, and a `· {project name}` suffix.
- [ ] Checking the box completes the task inline: `PATCH /api/tasks/:id` setting `status_id` to the tenant's terminal (DONE) status; optimistic update + `toast`; on error roll back.
- [ ] `[View all tasks →]` link navigates to `/tasks`; each task row's title links to `/tasks/:id` (opens task detail in a side panel per spec, not the calendar event panel).
- [ ] Accessibility: the panel is a labelled landmark region; checkboxes carry an `aria-label="Mark {title} complete"`; day-group headings use heading semantics; the close control is keyboard reachable.
**Schema / Interfaces:**
```ts
// useUpcomingTasks.ts
export function useUpcomingTasks():
  UseQueryResult<PaginatedResponse<TaskObject>, ApiError>; // due in [today, today+7d], non-terminal
```
**Acceptance:**
- [ ] Sidebar is collapsed on first load and remembers the user's last choice.
- [ ] Only non-terminal tasks due within the next 7 days are listed, grouped by day.
- [ ] Toggling a checkbox completes the task (`PATCH /api/tasks/:id` → terminal status) with optimistic UI and rollback on failure.
- [ ] Clicking a task opens task detail (side panel / `/tasks/:id`), not the calendar event panel.
