Documentation source
Task Planner & Productivity Blocks
World-class task management UX — nested task tree, drag-to-schedule day planner with shared DnD context, fast checklist. Adds scheduling fields to tasks, ships a killer /tasks/planner page, and deletes the unused calendar surface.
## Problem
The task primitive ships interactive CRUD, a detail page, and a task-board block — but every edit still happens through a form. There is no way to:
1. **See the day's work laid out in time.** Tasks have no scheduling fields. "Work on X at 9am for 45 minutes" has no representation.
2. **Plan a day fluidly.** You can't drag a task from a backlog onto a time slot and have it scheduled. Every scheduling change is a form submit.
3. **Experience Todoist-class UX.** The current task list has flat sorting with no nesting visualization, no drag-to-reorder, no drag-to-indent, no inline add, no keyboard navigation.
4. **Render tasks or entities on a real calendar.** The existing `calendar` block is a 248-line month-grid with static event dots — no day view, no week view, no drag, no resize. The `calendar` surface (432 lines) distributes blocks across a month grid and isn't used in any seed or config.
5. **Coordinate human + agent work in one view.** We have cron triggers, assigned-to, agent_slug — but nowhere to see "what am I and my agents doing today" laid out chronologically.
Cron triggers exist (`trigger_type='cron'` + `trigger_config.schedule`) but have no visual representation. Tasks spawned by cron have no way to carry a "when this should be worked on" signal separate from "when it was created". The claim loop needs a consistent way to pick up scheduled work whether it was placed by a human drag or a cron trigger.
## Solution
Focused additions that together deliver Todoist/Asana/Linear-class task UX for tasks.
1. **Schema:** add `due_at`, `scheduled_start`, `scheduled_end`, `priority` to the `tasks` table. Nullable, indexed, non-breaking. `metadata jsonb` already exists as the catchall.
2. **Three new blocks** — `task-tree`, `planner`, `checklist` — reusable anywhere tasks appear.
3. **One deleted surface** — `calendar-surface` — unused 432-line month-grid-of-blocks.
4. **One hardcoded page** — `/tasks/planner` — renders the planner block directly for a zero-friction "today" experience.
**Scope pivot (2026-04-11):** The original spec proposed replacing the existing 248-line `calendar-block.tsx` with the yassir-jeraidi full-calendar shadcn registry component. On closer inspection, that registry item is a 46-file feature module under `src/features/calendar/` with its own custom DnD context, dialogs, year view, and multiple additional runtime dependencies (`motion`, `framer-motion`, `re-resizable`) — far larger than presented in brainstorming. Its internal DnD would also conflict with the planner's shared @dnd-kit context. **The calendar block replacement is dropped from this PR.** The existing calendar block stays unchanged. The planner's day-timeline is built purpose-built on @dnd-kit as originally planned. A separate, smaller follow-up can upgrade the calendar block in isolation (e.g., add a simple week view built on shadcn Calendar primitive, no heavy registry import).
The planner is the killer flow: a single block that encapsulates both a flat task list (left) and a vertical day-timeline (right) inside one shared `@dnd-kit` DndContext, so users can drag tasks from the list directly onto time slots to schedule them. Existing scheduled events can be dragged vertically to reposition (mutates `scheduled_start` while preserving duration) and resized by their bottom handle to change duration (mutates `scheduled_end`).
Task responses, comments, and entity-style collaboration are explicitly deferred to a follow-up spec (`task-entity-docking`) so this PR ships a tight, reviewable surface.
## Design
### Architecture
```
┌─────────────────────────── /tasks/planner page ───────────────────────────┐
│ <PlannerBlock initialData /> │
│ ┌─────────────────── PlannerBlock ─────────────────────────────────────┐ │
│ │ DndContext (shared) │ │
│ │ ┌─────── PlannerList ──────┐ ┌─────── DayTimeline ──────────────┐ │ │
│ │ │ Unscheduled (draggable) │ │ 06:00 ┌────────────────────────┐ │ │ │
│ │ │ • Task A │ │ │ │ │ │ │
│ │ │ • Task B │ │ 07:00 │ │ │ │ │
│ │ │ • Task C │ │ │ │ │ │ │
│ │ │ Scheduled today │ │ 08:00 │ ┌─ Task X (09:00-9:45)┐│ │ │ │
│ │ │ • Task X (09:00) │ │ │ │ ││ │ │ │
│ │ │ • Task Y (14:00) │ │ 09:00 │ │ draggable ││ │ │ │
│ │ │ │ │ │ └─ resize handle ─────┘│ │ │ │
│ │ └──────────────────────────┘ │ 10:00 │ ┌─ drop zone ────────┐ │ │ │ │
│ │ │ │ └────────────────────┘ │ │ │ │
│ │ │ ... │ │ │ │ │
│ │ └───────┴────────────────────────┘ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
```
### Data model
New columns on `tasks` (all nullable):
| Column | Type | Purpose |
|---|---|---|
| `due_at` | `timestamptz` | Deadline for the task (independent of when you plan to work on it) |
| `scheduled_start` | `timestamptz` | When the task is planned to start |
| `scheduled_end` | `timestamptz` | When the task is planned to finish (duration = end − start) |
| `priority` | `text CHECK IN ('p0','p1','p2','p3','p4')` | Linear-style priority ranks |
Two partial indexes:
- `idx_tasks_scheduled` on `(tenant_id, scheduled_start) WHERE scheduled_start IS NOT NULL`
- `idx_tasks_due` on `(tenant_id, due_at) WHERE due_at IS NOT NULL`
**Start + end, not start + duration.** Standard across Google Calendar / iCal DTEND / Outlook. Trivial range queries (`WHERE scheduled_end > $from AND scheduled_start < $to`). Matches what full-calendar outputs on resize. Duration is a trivial UI computation.
**Cron + scheduled_start unification.** Cron triggers spawn task rows (existing behavior); the spawned row can carry `scheduled_start = now() + next occurrence`. The claim loop becomes: `WHERE (scheduled_start IS NULL OR scheduled_start <= now()) AND status = 'active'`. Human-planned work and cron-spawned work use the same pickup semantics.
### Dependencies
- `pnpm add dnd-kit-sortable-tree` — headless nested sortable tree on existing @dnd-kit. Zero extra deps.
(The full-calendar shadcn registry component was considered but dropped — see Scope pivot in the Solution section.)
### Blocks
#### `task-tree` (new)
Reusable nested Todoist-style list. Uses `dnd-kit-sortable-tree` for drag-to-reorder + horizontal-drag to indent/outdent. Click checkbox to complete. Inline add row at bottom. Enter = new sibling, Tab = indent, Shift+Tab = outdent.
Replaces the existing `TaskList` component for standalone use cases (the `/tasks` page and block embedding). `TaskList`'s entity-mode partition (system vs custom tasks with a collapsible system section) stays as the entity-detail-page UX — task-tree is the new general-purpose component, not a drop-in replacement for task-list on entity detail.
**Config:**
```ts
{
entityTypeId?: string; // scope to tasks under an entity type (optional)
entityId?: string; // scope to tasks under a specific entity (optional)
showSystem?: boolean; // default false
showCompleted?: boolean; // default false
sort?: "sort_order" | "priority" | "due_at"; // default "sort_order"
}
```
**Data:** `{ tree: TaskTree }` via existing `resolveTaskTree()` helpers.
#### `planner` (new, the killer flow)
Composite block containing `PlannerList` + `DayTimeline` inside one `DndContext`. Cannot be decomposed into separate blocks because the shared DnD context can't span sibling blocks rendered by a surface.
**Config:**
```ts
{
dayRangeStart?: number; // default 6 (6am)
dayRangeEnd?: number; // default 22 (10pm)
slotMinutes?: number; // default 15
defaultDurationMinutes?: number; // default 30
filter?: {
scope: "mine" | "all" | "agent";
agentSlug?: string;
};
date?: string; // ISO date string, defaults to today
entityTypeId?: string; // scope to an entity type (optional)
entityId?: string; // scope to a specific entity (optional)
}
```
**Data:**
```ts
{
unscheduled: TaskRecord[]; // not scheduled for today
scheduled: TaskRecord[]; // has scheduled_start within the date
dateISO: string; // the active date
me: { userId: string; tenantId: string };
agents: { slug: string; name: string }[]; // for filter dropdown
}
```
**Interactions:**
- **Drag from list → timeline slot**: sets `scheduled_start = slotTime`, `scheduled_end = slotTime + defaultDurationMinutes`. Optimistic update via React Query.
- **Drag event within timeline**: preserves duration, mutates `scheduled_start` + `scheduled_end` together.
- **Drag event bottom handle** (custom pointer events, not @dnd-kit): mutates `scheduled_end` only. Minimum duration = slotMinutes. Snaps to slot grid.
- **Drag from timeline → back to list**: clears `scheduled_start` / `scheduled_end` (unschedule).
- **Click empty slot**: placeholder for future quick-add (deferred; v1 shows no action).
- **Filter dropdown**: My tasks (where `assigned_to = me.userId`), All tasks, Agent (where `agent_slug = <slug>`). Server refetches on change.
**Overlap rendering:** v1 caps at 2 side-by-side columns (half-width each). If 3+ events overlap the same time slot, the third and later events render collapsed with a `+N` badge at the top-right of the visible events. Full overlap handling (interval tree, fan-out layout) is deferred.
**Snapping logic:** `scheduled_start` snaps to the nearest `slotMinutes` boundary on drag-reposition. `scheduled_end` snaps to the nearest boundary on drag-resize. Cross-snap (start and end both moving by the same delta) happens on move; independent snap (end only) happens on resize.
**Agent filter:** `filter.scope = "agent"` with `filter.agentSlug` filters by `tasks.agent_slug = <slug>`. `"mine"` filters by `tasks.assigned_to = me.userId`. `"all"` applies no assignee filter.
**Mobile layout:** below `768px` (md breakpoint), the list and timeline swap from side-by-side to a tab switcher — one tab shows the list, the other shows the timeline. Drag between list and timeline is not supported on mobile v1 (use tap-to-schedule in a future iteration).
#### `checklist` (new)
Flat, fast checkbox list. ~100 lines. Shadcn `Checkbox` + `Input` + keyboard handlers.
**Config:**
```ts
{
parentTaskId?: string; // render children of this task
entityTypeId?: string; // OR render tasks of this type
entityId?: string; // OR render tasks of this entity
showAdd?: boolean; // default true
}
```
**Interactions:**
- Click checkbox → toggle `status` between `active` / `completed`
- Click text → inline edit (blur or Enter to save)
- Bottom "Add a task…" row — Enter creates, focuses next blank
- Cmd+Enter or Ctrl+Enter on an item → mark complete, focus next
- Drag handle → reorder (mutates `sort_order`)
- Strike-through + muted color for completed items
#### `calendar` (replaced)
Swap existing `calendar-block.tsx` implementation for a wrapper around the yassir-jeraidi full-calendar component. Block type stays `calendar` — existing view configs keep working.
**Config (extends existing):**
```ts
{
view?: "day" | "week" | "month" | "agenda"; // default "month" for backward compat
sourceType?: "tasks" | "entities"; // default "tasks"
dateField?: string; // for entity source
endDateField?: string; // for entity source (falls back to dateField + 1h)
labelField?: string; // for entity source
colorField?: string; // for entity source
entityTypeSlug?: string; // for entity source
limit?: number;
}
```
When `sourceType: "tasks"`, the block queries `tasks` with `scheduled_start IS NOT NULL` in the visible range and renders them as events; drag-reposition and drag-resize inside the calendar update the task's schedule. When `sourceType: "entities"` (the default for backward compat), the block queries entities via the standard data source pipeline — matches the behavior of the old calendar block. Any existing view config keeps working unchanged.
### Surface deletion
- Delete `features/views/surfaces/calendar-surface.tsx` (432 lines)
- Delete `features/views/surfaces/calendar-surface.test.ts`
- Delete `features/views/surfaces/definitions/calendar.ts`
- Remove registration from `features/views/surfaces/register.ts`
- Remove entry from `features/views/surface-types.ts` and its test
- Add a guard in `resolveViewSurface()` so any in-DB view referencing `surface_type = 'calendar'` falls back to `grid` with a console warning (defensive — no audit hit found in seeds)
### `/tasks/planner` page
Hardcoded Next.js route. No db-driven view.
- `app/(app)/tasks/planner/page.tsx` — server component: `requireAuth()`, fetches today's unscheduled + scheduled tasks via new `getPlannerData()` server action, passes as `initialData` to the planner block
- `app/(app)/tasks/planner/loading.tsx` — skeleton
The page renders the planner block directly. No view config, no surface. The block handles all interactions internally.
### API surface
**New server action** `features/tasks/server/planner.ts`:
```ts
getPlannerData(input: {
tenantId: string;
userId: string;
dateISO: string;
filter: { scope: "mine" | "all" | "agent"; agentSlug?: string };
entityTypeId?: string;
entityId?: string;
}): Promise<PlannerData>
```
Queries tasks in two buckets:
1. **Scheduled**: `scheduled_start >= dayStart(dateISO, tz)` AND `scheduled_start < dayEnd(dateISO, tz)` AND status != 'completed', filtered by scope
2. **Unscheduled**: `scheduled_start IS NULL` AND (status = 'active' OR status = 'draft'), filtered by scope, limited to 50 top-priority
Date range uses the user's tenant timezone (from tenant settings, defaults to UTC).
**New React Query hook** `features/tasks/hooks/use-planner-data.ts`:
```ts
usePlannerData(input, opts?): UseQueryResult<PlannerData>
```
Invalidates on task mutations, realtime task updates (subscribes to `tasks` table filtered by tenant_id).
**Existing hook extensions** `features/tasks/hooks/use-tasks.ts`:
```ts
useScheduleTask() // mutation: sets scheduled_start + scheduled_end, optimistic update
useUnscheduleTask() // mutation: clears both, optimistic update
```
### Time math (pure functions)
`features/tasks/components/planner/time-math.ts` — fully unit-tested:
```ts
// Convert pixel offset in timeline to ISO timestamp on given date
pxToTime(pxY: number, opts: { dayStart: number; dayEnd: number; pxPerHour: number; date: Date; snapMinutes: number }): Date
// Convert ISO timestamp to pixel offset
timeToPx(iso: Date, opts: { dayStart: number; dayEnd: number; pxPerHour: number; date: Date }): number
// Snap a date to nearest slot boundary
snapToSlot(date: Date, snapMinutes: number): Date
// Clamp end time to valid range (min duration = snapMinutes)
clampEnd(start: Date, end: Date, snapMinutes: number): Date
// Compute duration in minutes
durationMinutes(start: Date, end: Date): number
```
All functions pure, timezone-aware via explicit date argument, no side effects.
### Drag-resize implementation
Full-calendar handles drag-resize internally for the `calendar` block. For the `planner` block's `DayTimeline`, we build resize ourselves on pointer events (not @dnd-kit, because @dnd-kit doesn't natively support edge-resize):
1. `onPointerDown` on the bottom 6px handle of an event card → capture pointer, record initial Y
2. `onPointerMove` (on the handle with captured pointer) → compute new end time via `pxToTime`, apply `clampEnd`, update local preview state
3. `onPointerUp` → fire `useScheduleTask.mutate({ scheduled_end: newEnd })`, release capture
Event drag-reposition uses @dnd-kit's `useDraggable` on the event card body (excluding the resize handle). On drag end, compute new start, preserve duration, apply.
### Shared DndContext strategy
`PlannerBlock` wraps both `PlannerList` and `DayTimeline` in one `<DndContext>`. Draggable items have typed data attached: `{ type: "task", taskId, duration?: number }`. Drop targets on the timeline have `{ type: "slot", slotTime: Date }`. The `onDragEnd` handler in `PlannerBlock`:
```ts
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over) return;
const draggedType = active.data.current?.type;
const dropType = over.data.current?.type;
if (draggedType === "task" && dropType === "slot") {
// from list or timeline → timeslot
const start = over.data.current.slotTime;
const duration = active.data.current.duration ?? defaultDurationMinutes;
const end = addMinutes(start, duration);
scheduleMutation.mutate({ id: active.id, scheduled_start: start, scheduled_end: end });
}
if (draggedType === "task" && dropType === "list") {
// from timeline → list = unschedule
unscheduleMutation.mutate({ id: active.id });
}
}
```
### TaskRecord, schemas, editor
`features/tasks/types.ts` — `TaskRecord` gains `due_at`, `scheduled_start`, `scheduled_end`, `priority` (all nullable). `TaskCreateSchema` and `TaskUpdateSchema` add matching optional fields with Zod `datetime()` validation.
`features/tasks/server/crud.ts` — pass the new fields through `createTask()` / `updateTask()` / `listTasks()`.
`features/tasks/components/task-editor-basics.tsx` — adds a "Schedule" disclosure section with:
- Date + time pickers for `scheduled_start` and `scheduled_end` (using existing shadcn date/time components — or Popover + Calendar + time input)
- Date picker for `due_at`
- Select for `priority` (None / P0 / P1 / P2 / P3 / P4)
No UI changes to the Trigger / Output / Instructions sections.
## Trade-offs
### Why standalone tasks table, not dual-storage entity dock (for this PR)
- Drag-resize UX wants immediate mutation, not "propose → judge → apply"
- Scope control: dual-storage adds ~200 lines of sync code + task entity type + backfill migration + PR 620 conflict surface
- Deferred to `task-entity-docking` spec. Purely additive — `task_entity_id` nullable FK, entity backfill via SQL, no breaking changes
- ADR-001 rejected dual-storage pre-planner. That decision stands for this PR and gets revisited in the follow-up when the collaboration use case is concrete
### Why two calendar components (planner day-timeline AND full-calendar block)
- `day-timeline` shares @dnd-kit context with the planner list → drag-from-list-to-slot works natively. Full-calendar uses its own DnD and doesn't support external drops without custom work
- `full-calendar` gives us polished week/month/agenda views for general embedding
- Cost: ~400 lines of custom day-timeline code duplicates some visual concepts from full-calendar's day view
- Benefit: the killer flow works, general-purpose calendar embedding works, each component is focused
- Alternative considered: build everything on @dnd-kit (including a custom month view). Rejected because ~500+ lines of additional layout code wasn't worth it when full-calendar handles week/month/agenda for free
- Alternative considered: use full-calendar for everything, drag-from-list via HTML5 native drag as a bridge. Rejected — cross-library DnD is brittle and the killer flow is important enough to purpose-build
### Why replace (not coexist with) the old calendar-block
- 248 lines of month-grid-only with zero interactivity
- No existing view config uses anything beyond defaults, so the new block in `view: "month"` mode is visually equivalent
- Coexistence would mean two `calendar` block types which is confusing
- Block type name stays `calendar` so view configs stay valid
### Why delete calendar-surface
- 432 lines, zero references in seeds, confusingly named (distributes blocks across month grid, not calendar events)
- Defensive fallback handles any orphan in-DB references (→ grid surface)
### Why start + end (not start + duration)
- Standard in Google Calendar, iCal DTEND, Outlook — matches ecosystem expectations
- Range queries are trivial (`WHERE scheduled_end > $from AND scheduled_start < $to`)
- Full-calendar emits new end on resize (not new duration) → direct storage
- Duration is a trivial UI computation
### Why hardcoded /tasks/planner (not a db-driven view)
- Shared DndContext can't cleanly span two blocks in a surface layout
- The planner IS a block, so a db-driven view of "planner block alone" adds indirection without benefit
- Hardcoded page renders the block directly — zero view config, instant load, embeddable elsewhere as a block
### Scope control
- No collaboration layer (responses on tasks) — deferred spec
- No DataSourceConfig discriminated union refactor — replaced by light targeted branching in calendar block resolver
- No week view on planner timeline — day only for v1
- No cron projection on timeline — v1 shows only tasks with `scheduled_start` set
- No recurring task UI, no reminders, no notifications, no Google Calendar sync
- No multi-day spanning events
- No overlap interval tree — v1 uses naive side-by-side columns
## Acceptance Criteria
**Schema**
- [ ] Migration `20260411000010_task_scheduling.sql` applies cleanly on a fresh `pnpm db:reset`
- [ ] `tasks` table has `due_at`, `scheduled_start`, `scheduled_end`, `priority` columns (all nullable)
- [ ] Two partial indexes exist: `idx_tasks_scheduled`, `idx_tasks_due`
- [ ] `pnpm db:types` regenerates types without manual edits
**Types & CRUD**
- [ ] `TaskRecord` includes the four new fields
- [ ] `TaskCreateSchema` / `TaskUpdateSchema` accept the four new fields as optional
- [ ] `createTask()` / `updateTask()` / `listTasks()` pass new fields through
- [ ] `listTasks()` accepts a `scheduledBetween: { from: Date; to: Date }` filter for planner queries
- [ ] Co-located `.test.ts` for new CRUD paths
- [ ] Existing task CRUD tests still pass (no breaking changes)
**Task editor**
- [ ] Basics tab has a Schedule section with scheduled_start / scheduled_end / due_at / priority inputs
- [ ] Form validates that `scheduled_end >= scheduled_start` when both set
- [ ] Existing tasks without schedule render without errors (nullable fields)
**`task-tree` block**
- [ ] Registered in block registry with correct metadata
- [ ] Renders nested tasks with drag-to-reorder (vertical) and drag-to-indent (horizontal)
- [ ] Checkbox toggles `status` between `active` and `completed`
- [ ] Inline add row creates a new task at the correct nesting level
- [ ] Enter / Tab / Shift-Tab keyboard shortcuts work
- [ ] Empty state renders correctly
- [ ] Co-located tests for the tree block component and the hook
- [ ] Works on entity detail pages and standalone `/tasks`
**`planner` block**
- [ ] Registered in block registry with correct metadata
- [ ] Renders two-pane layout (list + timeline)
- [ ] Timeline shows hour gridlines from 6am-10pm by default, 15-min slot snapping
- [ ] Shared `DndContext` wraps both panes
- [ ] Drag from list to timeline slot: sets `scheduled_start` + `scheduled_end`, optimistic update, task disappears from list and appears on timeline
- [ ] Drag event within timeline: preserves duration, updates start + end together
- [ ] Drag event back to list: clears both schedule fields
- [ ] Resize handle (bottom edge): updates `scheduled_end` only, snaps to grid, respects min duration
- [ ] Filter dropdown (My / All / Agent) refetches data on change
- [ ] Overlapping events render as side-by-side half-width columns
- [ ] Loading and empty states render correctly
- [ ] Mobile viewport (375px) stacks list above timeline (not side-by-side)
- [ ] Co-located tests for `time-math.ts` (full coverage of pure functions)
- [ ] Co-located tests for `planner-block` wiring (mocked hooks)
**`checklist` block**
- [ ] Registered with metadata
- [ ] Renders children of a parent task OR flat task list
- [ ] Click checkbox toggles completion, strike-through on done
- [ ] Inline add row at bottom — Enter creates, focuses next blank
- [ ] Cmd+Enter completes current item and focuses next
- [ ] Drag handle reorders (mutates sort_order)
- [ ] Co-located test
**`calendar` block (replaced)**
- [ ] Full-calendar shadcn registry component installed under `components/ui/full-calendar/`
- [ ] Block definition updated, config schema extended with `view`, `sourceType`, etc.
- [ ] Existing view configs using `calendar` block still render (backward compat — `sourceType` defaults to `entities` if `entityTypeSlug` present)
- [ ] `sourceType: "tasks"` (new default for naked configs) queries tasks and renders events
- [ ] Drag-reposition inside calendar updates scheduled times
- [ ] Drag-resize inside calendar updates scheduled_end
- [ ] Day / week / month / agenda views all work
- [ ] Old `calendar-block.tsx` deleted
**Calendar surface (deleted)**
- [ ] `calendar-surface.tsx`, its test, and its definition removed
- [ ] Removed from `register.ts` and `surface-types.ts`
- [ ] `surface-types.test.ts` passes without calendar entry
- [ ] Defensive fallback renders `grid` surface if an in-DB view references `calendar`
**`/tasks/planner` page**
- [ ] Route loads with SSR auth check
- [ ] Initial data is pre-fetched and passed as `initialData` to the planner block (no loading flash)
- [ ] Loading skeleton renders during navigation
- [ ] Renders the planner block directly (no view config)
- [ ] Navigation: `/tasks` gets a "Planner" link/tab that routes here
- [ ] Mobile viewport: stacks panes vertically, still usable
**Quality gates**
- [ ] `pnpm lint` clean
- [ ] `pnpm typecheck` clean
- [ ] `pnpm test` all passing
- [ ] `pnpm build` succeeds
- [ ] Visual verification at 1280x720 and 375x667 for `/tasks/planner`, `/tasks` (task-tree block on entity detail), and a test view with the calendar block
- [ ] No console errors on affected pages
- [ ] No hardcoded Tailwind colors — uses CSS variables
- [ ] No new file over 350 lines (enforce the component size limit)
**Dependencies**
- [ ] `dnd-kit-sortable-tree` added to `package.json`
- [ ] `motion` added (via full-calendar shadcn install)
- [ ] No `jotai` or other unexpected deps introduced
## Files
### New
```
supabase/migrations/20260411000010_task_scheduling.sql
features/tasks/components/planner/planner-block.tsx
features/tasks/components/planner/planner-block.test.tsx
features/tasks/components/planner/planner-list.tsx
features/tasks/components/planner/day-timeline.tsx
features/tasks/components/planner/day-timeline-event.tsx
features/tasks/components/planner/planner-filter.tsx
features/tasks/components/planner/time-math.ts
features/tasks/components/planner/time-math.test.ts
features/tasks/hooks/use-planner-data.ts
features/tasks/hooks/use-planner-data.test.ts
features/tasks/server/planner.ts
features/tasks/server/planner.test.ts
features/blocks/components/task-tree-block.tsx
features/blocks/components/task-tree-block.test.tsx
features/blocks/components/checklist-block.tsx
features/blocks/components/checklist-block.test.tsx
features/blocks/components/full-calendar-block.tsx
features/blocks/components/full-calendar-block.test.tsx
features/blocks/definitions/task-tree.ts
features/blocks/definitions/task-tree.test.ts
features/blocks/definitions/planner.ts
features/blocks/definitions/planner.test.ts
features/blocks/definitions/checklist.ts
features/blocks/definitions/checklist.test.ts
app/(app)/tasks/planner/page.tsx
app/(app)/tasks/planner/loading.tsx
components/ui/full-calendar/* (generated by shadcn install)
```
### Modified
```
features/tasks/types.ts — add schedule fields to TaskRecord + schemas
features/tasks/server/crud.ts — pass schedule fields through
features/tasks/hooks/use-tasks.ts — add useScheduleTask / useUnscheduleTask
features/tasks/components/task-editor-basics.tsx — add Schedule section
features/blocks/definitions/calendar.ts — point to new implementation, new config schema
features/blocks/definitions/index.ts — register task-tree, planner, checklist
features/blocks/registry.ts — register new block components
features/blocks/types.ts — add new block types to BLOCK_TYPES
features/blocks/config-schemas.ts — add config schemas for new blocks
features/blocks/lib/block-metadata.ts — add metadata for new blocks
features/views/surfaces/register.ts — remove calendar surface registration
features/views/surface-types.ts — remove calendar entry
features/views/surface-types.test.ts — update assertions
lib/supabase/database.types.ts — regenerated after migration
package.json / pnpm-lock.yaml — dnd-kit-sortable-tree + full-calendar deps
```
### Deleted
```
features/blocks/components/calendar-block.tsx
features/views/surfaces/calendar-surface.tsx
features/views/surfaces/calendar-surface.test.ts
features/views/surfaces/definitions/calendar.ts
```
## Follow-up: Task Entity Docking (future spec)
Out of scope for this PR; referenced here for continuity.
**Goal:** Enable responses, comments, tags, and collaboration on tasks without rewriting the tasks table.
**Approach:** Purely additive dual-storage — add `task_entity_id uuid REFERENCES entities(id)` to `tasks`. Create a `task` entity type. On task create, also create a minimal entity record with denormalized fields used by responses. Backfill entities for existing tasks via a single SQL migration function.
**Sync rules:**
- Task row is the source of truth for execution-layer fields (trigger, output, runtime, schedule).
- Entity content mirrors a small set of fields (name/title, description, priority, scheduled_start, scheduled_end, assigned_to, agent_slug) to enable response-backed proposals.
- Updates to mirrored fields on the task row sync forward to the entity (one-way).
- Responses promoted to the entity sync backward to the task row (one-way at promotion time).
- Deletion cascades entity → task (via `ON DELETE CASCADE`).
**Deliverables:**
- `tasks.task_entity_id` column + index + backfill
- `task` entity type seed (system type, cannot be deleted)
- Task CRUD sync hooks
- Criteria sets, responses, comments on tasks — all via existing entity systems
- Replaces the need for Phase 6 DataSourceConfig refactor entirely
Create a separate spec `task-entity-docking.mdx` when this work is prioritized.