# Tasks: Board Engine — Implementation Plan

**Spec:** docs/specs/2026-05-30-tasks-board-engine.md  ·  **Slug:** tasks-board-engine  ·  **Wave:** 4
**Depends on:** foundation-auth-rbac, foundation-design-system, projects-module

## Goal
Deliver the tenant-customizable task board engine: per-tenant/per-project task statuses, the `tasks` table with fractional-index column ordering, three view layouts (Kanban with `@dnd-kit`, List, Timeline/Gantt with dhtmlx GPL build), URL-synced filters, a Zustand board-preferences store, external task-adapter imports (Trello/Asana/Jira/Monday/ClickUp/Slack) on a Cloudflare Cron, and auto-create of tasks from inbound email/Telegram/Slack/WhatsApp messages. Task detail and real-time comments live in `tasks-detail-communication`; this spec owns the board, list, timeline, and the internal `/api/tasks*` surface.

## Architecture
- **DB (`@zync/db`, Drizzle/Neon Postgres):** three new tables — `task_statuses`, `tasks`, `task_labels` — plus a new `task_sync_settings` table for per-tenant adapter sync + inbound auto-create config. FKs are UUID→UUID against upstream `tenants`, `projects` (projects-module), and `users` (foundation-auth-rbac). One **modify** to the upstream `user_preferences` table: add `board_column_order JSONB` for per-user column ordering.
- **API (Hono, apps/zync-api):** internal `/api/tasks*` routes (list/create/update/delete, status CRUD + reorder, bulk) plus `/api/cron/tasks-sync`. Routes use upstream `authMiddleware`, `requirePermission('tasks:*')`, `requireModuleEnabled('tasks')`, `tenantQuery` for tenant-scoped DB access, `buildPaginated`/`encodeCursor`/`decodeCursor` for pagination, and Zod validation (require-zod-validation-in-routes). Responses reuse the locked `serializeTask`/`TaskObject` types from tenant-public-api where shapes align; the customizable-status board adds its own status DTOs.
- **Adapters (`@zync/integrations` package, new):** `TaskAdapter` interface + six implementations. Credentials loaded via upstream `loadAdapterCredential` + `decryptCredential` from `adapter_credentials`; sync results logged to upstream `integration_sync_logs`. Cron route iterates tenants with active adapters.
- **Inbound auto-create:** consumes the upstream `comms.inbound` queue / `routeInboundMessage` hook from system-communications-notifications; when `task_sync_settings.auto_create_tickets_from.<source> = true`, creates a task.
- **App (apps/zync-app, Vite+React):** `/tasks` route with view switcher (Kanban/List/Timeline), `@dnd-kit` board, dhtmlx Gantt, filter toolbar with URL search-param sync, and a `useBoardPreferences` Zustand store persisted to localStorage. Uses `@zync/ui` primitives (`DataTable`, `Sheet`, `Avatar`, `Badge`, `Popover`, `Select`, `EmptyState`).

## Tech Stack
- **Packages:** `@zync/db` (schema + queries), `@zync/types` (shared DTOs), `@zync/integrations` (new — adapters), `@zync/ui` (consumed).
- **Apps:** `apps/zync-api` (Hono on Cloudflare Workers), `apps/zync-app` (Vite+React on Workers).
- **Libraries:** `drizzle-orm`, `@dnd-kit/core`, `@dnd-kit/sortable`, `dhtmlx-gantt` (GPLv2 build — license note in repo docs), `zustand` + `zustand/middleware` persist, `zod`, `@tanstack/react-query`.
- **Cloudflare bindings:** Hyperdrive (Postgres), Cron Triggers (tasks-sync), `QUEUE` (`comms.inbound` consumer), `RATELIMIT_KV`/`RATE_LIMITER_WEBHOOK` (existing). Secret: `CRON_SECRET`, `INTEGRATION_ENCRYPTION_KEY` (existing).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Schema | 1, 2 | `packages/db/src/schema/tasks.ts`, migration, `user_preferences` modify | No (foundation for all) |
| B — Server core | 3, 4, 5 | `packages/db` queries, `apps/zync-api/src/routes/tasks*.ts` | After A; 3→4→5 sequential-ish |
| C — Adapters & jobs | 6, 7, 8 | `packages/integrations/*`, cron + queue consumer | After A/B schema; 6 before 7/8 |
| D — Client board | 9, 10, 11, 12, 13 | `apps/zync-app/src/features/tasks/*` | After B (API contract); 9 (store) parallel with 10–13 |
| E — A11y & polish | 14 | board + gantt components | After D |

## Tasks

### Task 1: Task board schema (`task_statuses`, `tasks`, `task_labels`, `task_sync_settings`)
**Blocks:** 2, 3, 4, 5, 6, 7, 8  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/tasks.ts`
- Modify: `packages/db/src/schema/index.ts` (export new tables)
**Steps:**
- [ ] Define the four tables in Drizzle matching the DDL below; export table objects and inferred types.
- [ ] Add indexes: `tasks (tenant_id, project_id, status_id)`, `tasks (tenant_id, status_id, position)`, `tasks (tenant_id, assignee_id)`, unique partial index on `tasks (tenant_id, source, external_id)` where `external_id IS NOT NULL` to prevent duplicate imports, `task_statuses (tenant_id, project_id, position)`, `task_labels (label)`.
- [ ] Re-export from `packages/db/src/schema/index.ts`.
**Schema / Interfaces:**
```sql
CREATE TABLE task_statuses (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id),
  project_id  UUID REFERENCES projects(id),            -- NULL = tenant-global default
  name        TEXT NOT NULL,
  color       TEXT NOT NULL,                            -- CSS variable name (design token) or hex
  position    INTEGER NOT NULL,
  is_terminal BOOLEAN NOT NULL DEFAULT false,           -- 'DONE'-type statuses
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE tasks (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id),
  project_id      UUID REFERENCES projects(id),
  status_id       UUID NOT NULL REFERENCES task_statuses(id),
  title           TEXT NOT NULL,
  description     JSONB,                                 -- Tiptap rich-text JSON
  priority        TEXT NOT NULL DEFAULT 'medium'
                    CHECK (priority IN ('low','medium','high','urgent')),
  assignee_id     UUID REFERENCES users(id),
  reporter_id     UUID NOT NULL REFERENCES users(id),
  due_date        DATE,
  estimated_hours NUMERIC(6,2),
  source          TEXT NOT NULL DEFAULT 'manual'
                    CHECK (source IN ('manual','email','telegram','slack','whatsapp','trello','asana','jira','monday','clickup')),
  external_id     TEXT,                                  -- ID in external system
  position        NUMERIC NOT NULL,                      -- fractional indexing for column order
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE task_labels (
  task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  label   TEXT NOT NULL,
  PRIMARY KEY (task_id, label)
);

CREATE TABLE task_sync_settings (
  id                       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id                UUID NOT NULL REFERENCES tenants(id),
  sync_interval_minutes    INTEGER NOT NULL DEFAULT 120,   -- default every 2h
  auto_create_tickets_from JSONB NOT NULL DEFAULT '{}'::jsonb, -- { email:bool, telegram:bool, slack:bool, whatsapp:bool }
  default_project_id       UUID REFERENCES projects(id),   -- project for auto-created tasks (nullable)
  created_at               TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at               TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id)
);
```
**Acceptance:**
- [ ] `pnpm --filter @zync/db drizzle-kit generate` produces a migration with all four tables, the priority/source CHECKs, and the unique partial dedup index.
- [ ] All FKs are UUID→UUID; `description` and `auto_create_tickets_from` are JSONB; `is_terminal` is BOOLEAN.

### Task 2: Modify `user_preferences` + default-status seeding on tenant creation
**Blocks:** 5, 11  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/db/src/schema/user_preferences.ts` (add `board_column_order`)
- Create: `packages/db/src/seed/task-statuses.ts`
- Modify: tenant-provisioning hook (the existing `seedTenantModules` call-site / tenant bootstrap in `@zync/db`)
**Steps:**
- [ ] Add column `board_column_order JSONB` (nullable) to upstream `user_preferences`; generate migration. Do NOT recreate the table.
- [ ] Implement `seedTaskStatuses(tx, tenantId)` inserting the 8 `DEFAULT_STATUSES` as tenant-global rows (`project_id = NULL`), `position` 0..7, with `is_terminal = true` only for `DONE`. Assign each a design-token color name (e.g. `--status-backlog`, `--status-todo`, … `--status-done`).
- [ ] Call `seedTaskStatuses` from the tenant-provisioning transaction alongside existing seeds.
**Schema / Interfaces:**
```ts
export const DEFAULT_STATUSES = ['BACKLOG','TODO','IN_PROGRESS','BLOCKED','REVIEW','TESTING','DEPLOY','DONE'] as const;
export async function seedTaskStatuses(tx: Db, tenantId: string): Promise<void>;
// user_preferences gains: board_column_order JSONB  -- { [statusId: string]: number }
```
**Acceptance:**
- [ ] Creating a tenant inserts exactly 8 tenant-global statuses, only `DONE` has `is_terminal = true`, positions 0..7 contiguous.
- [ ] `user_preferences.board_column_order` column exists; migration is additive.

### Task 3: Fractional-indexing helpers + tenant-scoped task queries
**Blocks:** 4, 5  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/tasks.ts`
- Create: `packages/db/src/lib/fractional-index.ts`
**Steps:**
- [ ] Implement `positionBetween(before: number | null, after: number | null): number` returning the midpoint (`before+after`/2), `before+1` when appending, `after-1` when prepending, `1` for an empty column.
- [ ] Implement `needsRebalance(before, after): boolean` true when `Math.abs(after - before) < 0.001`; and `rebalanceColumn(tx, tenantId, statusId)` that renumbers a column's tasks to integer positions 1..n in current `position` order.
- [ ] Implement query functions below using `tenantQuery` (never raw drizzle from routes — no-raw-drizzle-from-routes): `listTasks`, `getTask`, `createTask`, `updateTask`, `deleteTask`, `bulkUpdateStatus`, plus label set helpers `setTaskLabels`.
- [ ] `listTasks` applies all filters (project, status[], priority[], assignee, source[], due range, labels[], q full-text on title), cursor pagination via `encodeCursor`/`decodeCursor`, and orders by `(status_id, position)`.
**Schema / Interfaces:**
```ts
export function positionBetween(before: number | null, after: number | null): number;
export function needsRebalance(before: number | null, after: number | null): boolean;
export async function rebalanceColumn(tx: Db, tenantId: string, statusId: string): Promise<void>;

export interface TaskFilters {
  project?: string; status?: string[]; priority?: string[]; assignee?: string;
  source?: string[]; dueFrom?: string; dueTo?: string; labels?: string[]; q?: string;
}
export async function listTasks(db: Db, tenantId: string, filters: TaskFilters, cursor?: string, limit?: number): Promise<{ rows: TaskObject[]; nextCursor: string | null }>;
export async function getTask(db: Db, tenantId: string, id: string): Promise<TaskObject | null>;
export async function createTask(db: Db, tenantId: string, input: CreateTaskInput): Promise<TaskObject>;
export async function updateTask(db: Db, tenantId: string, id: string, patch: UpdateTaskInput): Promise<TaskObject>;
export async function deleteTask(db: Db, tenantId: string, id: string): Promise<void>;
export async function bulkUpdateStatus(db: Db, tenantId: string, taskIds: string[], statusId: string): Promise<number>;
```
**Acceptance:**
- [ ] Dropping a card between positions 1.0 and 2.0 yields 1.5; repeated halving under 0.001 triggers `rebalanceColumn` which restores integer ordering with stable visual order.
- [ ] `updateTask` with new `status_id` + `position` moves the card across columns atomically.

### Task 4: Status CRUD + reorder queries
**Blocks:** 5  ·  **Blocked by:** 1, 3
**Files:**
- Create: `packages/db/src/queries/task-statuses.ts`
**Steps:**
- [ ] Implement `listStatuses(db, tenantId, projectId?)` returning project-specific statuses when present, else tenant-global, ordered by `position`.
- [ ] Implement `createStatus`, `updateStatus`, `deleteStatus`, `reorderStatuses`.
- [ ] `deleteStatus` must reject (or require a `reassignToStatusId`) when tasks still reference the status — reassign tasks first, then delete.
- [ ] `reorderStatuses(db, tenantId, order: {id, position}[])` updates positions in one transaction.
**Schema / Interfaces:**
```ts
export async function listStatuses(db: Db, tenantId: string, projectId?: string): Promise<TaskStatusRow[]>;
export async function createStatus(db: Db, tenantId: string, input: { name: string; color: string; projectId?: string; isTerminal?: boolean }): Promise<TaskStatusRow>;
export async function updateStatus(db: Db, tenantId: string, id: string, patch: Partial<{ name: string; color: string; isTerminal: boolean }>): Promise<TaskStatusRow>;
export async function deleteStatus(db: Db, tenantId: string, id: string, reassignToStatusId: string): Promise<void>;
export async function reorderStatuses(db: Db, tenantId: string, order: { id: string; position: number }[]): Promise<void>;
```
**Acceptance:**
- [ ] Deleting a status with tasks fails unless `reassignToStatusId` is provided, after which all tasks move and the status is removed.
- [ ] Reorder persists contiguous `position` integers.

### Task 5: Internal `/api/tasks*` routes (Hono)
**Blocks:** 9, 10, 11, 12, 13  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/tasks.ts`
- Create: `apps/zync-api/src/routes/task-statuses.ts`
- Create: `apps/zync-api/src/validation/tasks.ts` (Zod schemas)
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] Apply `authMiddleware`, then `requireModuleEnabled('tasks')` to the whole router.
- [ ] Define Zod schemas (require-zod-validation-in-routes): `createTaskSchema`, `updateTaskSchema`, `bulkUpdateSchema`, `createStatusSchema`, `updateStatusSchema`, `reorderStatusSchema`, and a `taskFiltersSchema` parsing the query params below.
- [ ] Implement each endpoint, gating with `requirePermission`: read→`tasks:read`, create/edit→`tasks:write`, delete→`tasks:delete`, assignee-change→`tasks:assign`. Serialize via `serializeTask`.
- [ ] `GET /api/tasks` returns the `buildPaginated(rows, nextCursor)` shape; parse comma-separated multi-value params (status, priority, source, labels).
- [ ] `PATCH /api/tasks/:id` handles status+position moves and triggers rebalance via Task 3 helpers; if `assignee_id` changes, additionally require `tasks:assign`.
**Schema / Interfaces:**
```
GET    /api/tasks                  -> tasks:read   (query: project, status, priority, assignee, source, due_from, due_to, labels, q, cursor, limit)
POST   /api/tasks                  -> tasks:write
PATCH  /api/tasks/:id              -> tasks:write (+ tasks:assign when assignee_id changes)
DELETE /api/tasks/:id              -> tasks:delete
GET    /api/tasks/statuses         -> tasks:read   (query: project?)
POST   /api/tasks/statuses         -> tasks:write
PATCH  /api/tasks/statuses/:id     -> tasks:write
DELETE /api/tasks/statuses/:id     -> tasks:write  (body: { reassignToStatusId })
PATCH  /api/tasks/statuses/reorder -> tasks:write  (body: { order: {id,position}[] })
POST   /api/tasks/bulk             -> tasks:write  (body: { taskIds: string[], statusId })
```
**Acceptance:**
- [ ] A user without `tasks:assign` gets 403 when changing assignee but can still edit other fields with `tasks:write`.
- [ ] Module disabled → router returns the `requireModuleEnabled` degraded response.
- [ ] All bodies/queries are Zod-validated; invalid input returns 422 `ApiError`.

### Task 6: `TaskAdapter` interface + six provider adapters
**Blocks:** 7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/integrations/src/tasks/types.ts`
- Create: `packages/integrations/src/tasks/{trello,asana,jira,monday,clickup,slack}.ts`
- Create: `packages/integrations/src/tasks/registry.ts`
- Create: `packages/integrations/package.json` (if package new) and tsconfig
**Steps:**
- [ ] Define `TaskAdapter`, `ExternalTask`, and `AdapterCredentials` (the decrypted credential shape) types per the interface below.
- [ ] Implement each adapter's `fetchTasks` (read external API) and `mapToTask` (normalize to `Partial<TaskObject>` with `source` set to the adapter id and `external_id` set). Implement optional `pushUpdate` for Trello and Jira only (Slack/others omit — 1-way).
- [ ] Export `getTaskAdapter(id): TaskAdapter` and `TASK_ADAPTER_IDS` from `registry.ts`.
- [ ] Each adapter normalizes external status to the closest tenant status by name; unmatched → first non-terminal status.
**Schema / Interfaces:**
```ts
export interface ExternalTask { externalId: string; title: string; description?: unknown; status?: string; assigneeEmail?: string; dueDate?: string; }
export interface TaskAdapter {
  id: string;       // 'trello' | 'asana' | 'jira' | 'monday' | 'clickup' | 'slack'
  name: string;
  fetchTasks(credentials: AdapterCredentials): Promise<ExternalTask[]>;
  mapToTask(external: ExternalTask, tenantId: string, projectId: string): Partial<TaskObject>;
  pushUpdate?(task: TaskObject, credentials: AdapterCredentials): Promise<void>;
}
export const TASK_ADAPTER_IDS: readonly string[];
export function getTaskAdapter(id: string): TaskAdapter;
```
**Acceptance:**
- [ ] Re-importing the same external task (same `source`+`external_id`) is a no-op (upsert), not a duplicate (enforced by Task 1 unique index).
- [ ] Trello and Jira expose `pushUpdate`; Slack does not.

### Task 7: Cron sync route `/api/cron/tasks-sync` (CRON_SECRET-protected)
**Blocks:** —  ·  **Blocked by:** 5, 6
**Files:**
- Create: `apps/zync-api/src/routes/cron-tasks-sync.ts`
- Modify: `apps/zync-api/wrangler.toml` (add cron trigger), `apps/zync-api/src/index.ts` (mount)
**Steps:**
- [ ] Authenticate the request by comparing the `Authorization`/`X-Cron-Secret` header to `CRON_SECRET` using `timingSafeEqual` (no-string-equality-for-tokens). Reject 401 on mismatch.
- [ ] For each tenant with active adapters (rows in `adapter_credentials` for task adapter ids whose `task_sync_settings.sync_interval_minutes` window has elapsed since `last_synced_at`): load+`decryptCredential` via `loadAdapterCredential`, call `adapter.fetchTasks`, `mapToTask`, upsert tasks.
- [ ] Write one row to `integration_sync_logs` per adapter run (status, items_count, error_msg).
- [ ] Add a manual trigger path reusing this logic, invokable from board settings ("sync now").
**Schema / Interfaces:**
```
POST /api/cron/tasks-sync   -> header CRON_SECRET (timing-safe); iterates tenants, runs adapters, logs to integration_sync_logs
# wrangler.toml: [triggers] crons = ["0 */2 * * *"]
```
**Acceptance:**
- [ ] Wrong/absent secret → 401 with no DB work performed; comparison is timing-safe.
- [ ] Each adapter run appends exactly one `integration_sync_logs` row with accurate `items_count`.

### Task 8: Inbound-message auto-create consumer
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-api/src/queue/tasks-inbound.ts`
- Modify: `apps/zync-api/src/index.ts` (register `comms.inbound` queue consumer / `routeInboundMessage` handler)
**Steps:**
- [ ] Subscribe to the upstream `comms.inbound` queue / register with `routeInboundMessage`; receive `InboundMessage` items carrying `{ tenantId, source, subject?, text }`.
- [ ] Load `task_sync_settings` for the tenant; if `auto_create_tickets_from[source] !== true`, skip.
- [ ] WhatsApp source is Enterprise-only: additionally gate via upstream tier check (`requireTier`/`meetsMinimumTier`) before creating.
- [ ] Create a task: `title` = subject (email) or message text (telegram/slack/whatsapp); `description` = email body (as Tiptap JSON) when present; `source` set; `project_id` = `task_sync_settings.default_project_id`; `reporter_id` = a system/tenant-owner user; status = first non-terminal status; `position` appended.
**Schema / Interfaces:**
```ts
// consumes upstream InboundMessage; gated by task_sync_settings.auto_create_tickets_from
async function handleInboundForTasks(msg: InboundMessage, env: Env): Promise<void>;
```
**Acceptance:**
- [ ] With `auto_create_tickets_from.email = true`, an inbound email creates one task titled from the subject; with the flag false, nothing is created.
- [ ] WhatsApp auto-create only fires for Enterprise-tier tenants.

### Task 9: Board-preferences Zustand store
**Blocks:** 10, 11, 13  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/tasks/store/board-preferences.ts`
**Steps:**
- [ ] Create a `zustand` store with `persist` middleware to localStorage key `board_columns_{userId}` for collapse state and `board_preferences` for view/columnOrder.
- [ ] Expose actions: `setView`, `setColumnOrder`, `toggleColumnCollapsed`.
- [ ] On `setColumnOrder`, also `PATCH /api/user/preferences` to persist `board_column_order` server-side (cross-device).
**Schema / Interfaces:**
```ts
interface BoardPreferences {
  view: 'kanban' | 'list' | 'timeline';
  columnOrder: Record<string, number>;        // statusId -> display position
  collapsedColumns: Record<string, boolean>;
}
export const useBoardPreferences: UseBoundStore<StoreApi<BoardPreferences & BoardActions>>;
```
**Acceptance:**
- [ ] View choice and collapsed columns survive reload; column order also round-trips through `user_preferences.board_column_order`.

### Task 10: Filter toolbar with URL search-param sync
**Blocks:** 11, 12, 13  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/tasks/components/FilterToolbar.tsx`
- Create: `apps/zync-app/src/features/tasks/hooks/useTaskFilters.ts`
**Steps:**
- [ ] `useTaskFilters` reads/writes URL search params (`project`, `status`, `priority`, `assignee`, `source`, `due_from`, `due_to`, `labels`, `q`); multi-value params are comma-separated. State is URL-native (bookmarkable, back-button safe).
- [ ] Render filter controls and active-filter chips below the toolbar; each chip has a × to remove; "Reset filters" clears all params.
- [ ] Pass parsed filters to `useTaskList` (react-query) which hits `GET /api/tasks`.
**Schema / Interfaces:**
```ts
export function useTaskFilters(): { filters: TaskFilters; setFilter: (k, v) => void; removeFilter: (k) => void; reset: () => void };
```
**Acceptance:**
- [ ] Editing a filter updates the URL and refetches; reloading the URL restores identical filter state; back button reverts the previous filter set.

### Task 11: Kanban view (`@dnd-kit`) with keyboard a11y
**Blocks:** 14  ·  **Blocked by:** 5, 9, 10
**Files:**
- Create: `apps/zync-app/src/features/tasks/views/KanbanBoard.tsx`
- Create: `apps/zync-app/src/features/tasks/components/{TaskCard,Column}.tsx`
**Steps:**
- [ ] Build columns from statuses (ordered by store `columnOrder`); each column renders its tasks sorted by `position` inside a `SortableContext`.
- [ ] Configure sensors with BOTH `PointerSensor` and `KeyboardSensor({ coordinateGetter: sortableKeyboardCoordinates })`.
- [ ] On drag end across columns: optimistic update then `PATCH /api/tasks/:id` with new `status_id` + `position` (computed via fractional index); within-column reorder updates `position` only. Roll back optimistic state on error.
- [ ] Column header: status name, count badge, "+ Add task". Collapsed column: vertical label + count, collapse state from store. Column reorder via header drag persists to `columnOrder`.
- [ ] Task card shows title, priority badge, assignee `Avatar`, due date (red if overdue), label chips, attachment count, comment count; hover quick actions (assign, set priority, set due date).
- [ ] Wrap drag transitions to respect `prefers-reduced-motion` (disable/instant animation when set).
**Schema / Interfaces:**
```ts
const sensors = useSensors(
  useSensor(PointerSensor),
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const announcements: Announcements = {
  onDragStart({ active }) { return `Picked up task ${active.data.current?.title}.`; },
  onDragOver({ active, over }) { return over ? `Moving task ${active.data.current?.title} over column ${over.data.current?.status}.` : undefined; },
  onDragEnd({ active, over }) { return over ? `Task ${active.data.current?.title} moved to column ${over.data.current?.status}.` : `Task ${active.data.current?.title} returned to original position.`; },
  onDragCancel({ active }) { return `Drag cancelled for task ${active.data.current?.title}.`; },
};
// Each SortableItem: role="button" aria-label="Drag to reorder: {title}"
// Column droppable: aria-label="{status name} column — {count} tasks"
```
**Acceptance:**
- [ ] A card can be picked up with Space/Enter, moved with arrows, dropped with Space/Enter, cancelled with Escape — no pointer required (WCAG 2.1.1).
- [ ] `DndContext` announces pickup/move/drop/cancel to the live region.
- [ ] With `prefers-reduced-motion: reduce`, drag animations are suppressed.

### Task 12: List view
**Blocks:** 14  ·  **Blocked by:** 5, 10
**Files:**
- Create: `apps/zync-app/src/features/tasks/views/TaskListView.tsx`
**Steps:**
- [ ] Render a `DataTable` with columns Title, Status, Priority, Assignee, Project, Due date, Created; sortable headers.
- [ ] Inline status/priority edit via `Popover` + `Select` calling `PATCH /api/tasks/:id`.
- [ ] Row click navigates to task detail route (owned by tasks-detail-communication).
**Acceptance:**
- [ ] Columns sort; inline status/priority edits persist and reflect immediately; the same URL filters apply as Kanban.

### Task 13: Timeline / Gantt view (dhtmlx GPL) with keyboard a11y
**Blocks:** 14  ·  **Blocked by:** 5, 9, 10
**Files:**
- Create: `apps/zync-app/src/features/tasks/views/TimelineView.tsx`
- Modify: repo `docs/` license note for dhtmlx GPLv2 usage
**Steps:**
- [ ] Render tasks with start/due dates on a dhtmlx Gantt (GPL build). Group by Project (default) / Status / Assignee; Zoom Day/Week/Month. Tasks without dates shown in an unscheduled column (read-only).
- [ ] Drag-to-reschedule updates `due_date` via `PATCH /api/tasks/:id`.
- [ ] Keyboard equivalents (no drag-only interaction): bars `role="button"` `aria-label="Task '{title}': {startDate} to {endDate}"`, focusable via Tab; Left/Right Arrow on a focused bar extends/shrinks duration by 1 day; Enter/Space opens task detail.
- [ ] Row reordering via the same `KeyboardSensor` pattern (Space pick up, Arrow reorder rows, Space/Enter drop, Escape cancel) with `DndContext` `announcements` (`Picked up task {title}`, `Moved {title} to row {n}`, `Dropped {title}`).
- [ ] Left/Right Arrow on the timeline grid container (when no bar focused) scrolls the date window; focus management ensures only the focused element receives arrows.
- [ ] Add a visually-hidden `<caption>` on the Gantt table: `"{n} tasks, spanning {startDate} to {endDate}"`. Respect `prefers-reduced-motion`.
**Acceptance:**
- [ ] Every resize/reorder operation has a keyboard equivalent; a keyboard-only user can resize a bar, reorder a row, and scroll the grid (WCAG 2.1.1).
- [ ] The Gantt table exposes the visually-hidden caption summary to screen readers.

### Task 14: View switcher, empty/error states, board-settings panel, a11y verification
**Blocks:** —  ·  **Blocked by:** 11, 12, 13
**Files:**
- Create: `apps/zync-app/src/features/tasks/TasksBoardPage.tsx`
- Create: `apps/zync-app/src/features/tasks/components/BoardSettingsPanel.tsx`
**Steps:**
- [ ] Mount `/tasks` route with a view switcher (Kanban/List/Timeline) bound to the store's `view`; lazy-load the Gantt bundle.
- [ ] Use `@zync/ui` `EmptyState` for no-tasks and `ErrorState`/`ErrorPage` for load failures.
- [ ] Build the board-settings panel (owned by this spec, not a `/settings` route): edit statuses (CRUD + reorder via `/api/tasks/statuses*`), configure adapter sync interval + "sync now", and the per-source `auto_create_tickets_from` toggles writing to `task_sync_settings`.
- [ ] Run an a11y pass: keyboard-only traversal of Kanban + Gantt, live-region announcements present, `prefers-reduced-motion` honored across views.
**Acceptance:**
- [ ] Switching views preserves active URL filters and board preferences.
- [ ] Status edits in the settings panel reflect on the board; sync-now triggers a sync and surfaces the latest `integration_sync_logs` result.
- [ ] Keyboard-only operation of all three views passes (no drag-only paths).
