Skip to main content

Todos Two-Pane Planner — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Replace the current Todos page (MyTodoListPage) with a two-pane “planner”: a left rail of todos grouped by due-date bucket beside a reused week-appointments calendar, with due-dated todos shown as all-day chips — reusing existing data, CRUD, and the WeekViewV2 calendar, with zero DB schema change. Architecture: A new TodoPlannerPage keeps MyTodoListPage’s data layer verbatim (same useAuth, ['doctor-todos'] query, three mutations, and the showFormTodoFormPage branch) and swaps the list body for <TodoRail> + <TodoWeekCalendar>. Bucketing is a pure, unit-tested function. The calendar reuses SchedulerProvider + WeekViewV2 read-only (week view has no drag), seeded by the existing getAppointments API and a small extracted mapAppointmentsToEvents helper. WeekViewV2 gets one additive, optional prop (renderAllDayCell) for the chip strip — omitted by the Appointments calendar, so it is unaffected. Tech Stack: React 19, TypeScript, Vite, TanStack Query v5, react-router-dom v7, date-fns v4, Radix/shadcn UI primitives, vitest + Testing Library, lucide-react.

File structure

New:
  • ui/src/components/todos/lib/bucketTodos.ts — pure bucketing + default-due-date logic.
  • ui/src/components/todos/lib/bucketTodos.test.ts — vitest unit tests.
  • ui/src/lib/appointments-to-events.ts — pure Appointment[] → Event[] mapper (shared).
  • ui/src/lib/appointments-to-events.test.ts — vitest unit test.
  • ui/src/components/todos/planner/TodoRailRow.tsx — one todo row (checkbox + title + meta + hover actions).
  • ui/src/components/todos/planner/TodoBucket.tsx — collapsible bucket section + inline quick-add.
  • ui/src/components/todos/planner/TodoRail.tsx — dark rail (header, search, buckets, done footer).
  • ui/src/components/todos/planner/TodoWeekCalendar.tsx — right pane (week header, provider, WeekViewV2, all-day chips).
  • ui/src/components/todos/TodoPlannerPage.tsx — orchestrator (data, URL state, responsive split, form branch).
Modified:
  • ui/src/components/schedule/_components/view/week/week-view-v2.tsx — add optional renderAllDayCell prop (additive only).
  • ui/src/components/dashboards/DoctorDashboard.tsx — render TodoPlannerPage for activeView==='todos'.
  • ui/src/components/dashboards/AdminDashboard.tsx — same.
  • ui/src/lib/nav-registry.ts — add todos entry to MODULE_PARAMS.
Unchanged: all DB schema/migrations, permissions.ts (client+server), clinic_modules, serverComm todo CRUD, TodoFormPage, AppointmentCalendar.tsx. Commands: typecheck cd ui && npx tsc --noEmit; tests cd ui && npx vitest run <path>; build cd ui && npm run build; dev cd ui && npm run dev.

Task 1: Pure bucketing logic (bucketTodos)

Files:
  • Create: ui/src/components/todos/lib/bucketTodos.ts
  • Test: ui/src/components/todos/lib/bucketTodos.test.ts
Bucketing uses local date strings (matching MyTodoListPage’s existing new Date(dueAt)/isPast behavior and format(new Date(dueAt),'d MMM yyyy') display; clinic users are in PKT/UTC+5 and dueAt is stored at noon UTC, so local interpretation matches the displayed date). Week starts Monday to match WeekViewV2.
  • Step 1: Write the failing test
// ui/src/components/todos/lib/bucketTodos.test.ts
import { describe, it, expect } from 'vitest';
import { bucketTodos, bucketDefaultDueIso, type BucketKey } from './bucketTodos';
import type { DoctorTodo } from '@/lib/serverComm';

// Fixed "now": Wednesday 2026-05-27, 10:00 local.
const NOW = new Date(2026, 4, 27, 10, 0, 0);

function todo(partial: Partial<DoctorTodo>): DoctorTodo {
  return {
    id: Math.random().toString(36).slice(2),
    clinicId: 'c1',
    createdBy: 'u1',
    title: null,
    body: 'b',
    voiceLang: null,
    linkedKind: null,
    linkedId: null,
    status: 'open',
    pinned: false,
    dueAt: null,
    assignedTo: null,
    tags: [],
    createdAt: '2026-05-01T00:00:00.000Z',
    updatedAt: '2026-05-01T00:00:00.000Z',
    ...partial,
  } as DoctorTodo;
}

// dueAt is stored at noon UTC; build the same way the app does.
const due = (y: number, m: number, d: number) => new Date(Date.UTC(y, m, d, 12, 0, 0)).toISOString();

describe('bucketTodos', () => {
  it('puts an open todo due before today in overdue', () => {
    const t = todo({ dueAt: due(2026, 4, 25) }); // Mon 25th, before Wed 27th
    const b = bucketTodos([t], NOW);
    expect(b.overdue.map((x) => x.id)).toEqual([t.id]);
    expect(b.thisWeek).toHaveLength(0);
  });

  it('puts an open todo due later this week (Mon-Sun) in thisWeek', () => {
    const t = todo({ dueAt: due(2026, 4, 29) }); // Fri 29th, same ISO week as Wed 27th
    expect(bucketTodos([t], NOW).thisWeek.map((x) => x.id)).toEqual([t.id]);
  });

  it('puts an open todo due later this month (after this week) in thisMonth', () => {
    const t = todo({ dueAt: due(2026, 4, 31) }); // Sun 31st — next week, still May
    expect(bucketTodos([t], NOW).thisMonth.map((x) => x.id)).toEqual([t.id]);
  });

  it('puts an open todo due beyond this month in later', () => {
    const t = todo({ dueAt: due(2026, 5, 10) }); // 10 Jun
    expect(bucketTodos([t], NOW).later.map((x) => x.id)).toEqual([t.id]);
  });

  it('puts an open todo with no due date in noDue', () => {
    const t = todo({ dueAt: null });
    expect(bucketTodos([t], NOW).noDue.map((x) => x.id)).toEqual([t.id]);
  });

  it('puts done todos in done and excludes archived entirely', () => {
    const d = todo({ status: 'done', dueAt: due(2026, 4, 28) });
    const a = todo({ status: 'archived' });
    const b = bucketTodos([d, a], NOW);
    expect(b.done.map((x) => x.id)).toEqual([d.id]);
    expect([...b.overdue, ...b.thisWeek, ...b.thisMonth, ...b.later, ...b.noDue]).toHaveLength(0);
  });

  it('sorts pinned first, then by dueAt ascending, within a bucket', () => {
    const a = todo({ dueAt: due(2026, 4, 29), pinned: false });
    const b = todo({ dueAt: due(2026, 4, 28), pinned: false });
    const p = todo({ dueAt: due(2026, 4, 30), pinned: true });
    const out = bucketTodos([a, b, p], NOW).thisWeek.map((x) => x.id);
    expect(out).toEqual([p.id, b.id, a.id]);
  });

  it('bucketDefaultDueIso lands an item in its own bucket', () => {
    const keys: BucketKey[] = ['thisWeek', 'thisMonth', 'later'];
    for (const k of keys) {
      const iso = bucketDefaultDueIso(k, NOW)!;
      const placed = bucketTodos([todo({ dueAt: iso })], NOW);
      expect(placed[k].length, k).toBe(1);
    }
    expect(bucketDefaultDueIso('noDue', NOW)).toBeNull();
  });
});
  • Step 2: Run test to verify it fails
Run: cd ui && npx vitest run src/components/todos/lib/bucketTodos.test.ts Expected: FAIL — Cannot find module './bucketTodos'.
  • Step 3: Write the implementation
// ui/src/components/todos/lib/bucketTodos.ts
import {
  startOfDay, endOfDay, startOfWeek, endOfWeek, endOfMonth, addDays,
  isBefore, isAfter, isWithinInterval,
} from 'date-fns';
import type { DoctorTodo } from '@/lib/serverComm';

export type BucketKey = 'overdue' | 'thisWeek' | 'thisMonth' | 'later' | 'noDue' | 'done';

export interface BucketedTodos {
  overdue: DoctorTodo[];
  thisWeek: DoctorTodo[];
  thisMonth: DoctorTodo[];
  later: DoctorTodo[];
  noDue: DoctorTodo[];
  done: DoctorTodo[];
}

const WEEK_OPTS = { weekStartsOn: 1 as const }; // Monday

function sortBucket(a: DoctorTodo, b: DoctorTodo): number {
  if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
  const ad = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY;
  const bd = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY;
  if (ad !== bd) return ad - bd;
  return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
}

export function bucketTodos(todos: DoctorTodo[], now: Date): BucketedTodos {
  const todayStart = startOfDay(now);
  const weekEnd = endOfWeek(now, WEEK_OPTS);
  const monthEnd = endOfMonth(now);

  const out: BucketedTodos = { overdue: [], thisWeek: [], thisMonth: [], later: [], noDue: [], done: [] };

  for (const t of todos) {
    if (t.status === 'archived') continue;
    if (t.status === 'done') { out.done.push(t); continue; }
    if (!t.dueAt) { out.noDue.push(t); continue; }

    const d = new Date(t.dueAt);
    if (isBefore(d, todayStart)) out.overdue.push(t);
    else if (!isAfter(d, weekEnd)) out.thisWeek.push(t);     // today .. end of this week
    else if (!isAfter(d, monthEnd)) out.thisMonth.push(t);   // after this week .. end of month
    else out.later.push(t);
  }

  for (const k of Object.keys(out) as BucketKey[]) out[k].sort(sortBucket);
  return out;
}

/** A due date (ISO, noon-UTC) that lands a new todo in `bucket`. Returns null for noDue. */
export function bucketDefaultDueIso(bucket: BucketKey, now: Date): string | null {
  const noon = (d: Date) => new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), 12, 0, 0)).toISOString();
  switch (bucket) {
    case 'thisWeek': return noon(now);
    case 'thisMonth': {
      const afterWeek = addDays(endOfWeek(now, WEEK_OPTS), 1);
      const monthEnd = endOfMonth(now);
      return noon(isAfter(afterWeek, monthEnd) ? monthEnd : afterWeek);
    }
    case 'later': return noon(addDays(endOfMonth(now), 1));
    default: return null; // noDue / overdue / done
  }
}

/** Open todos whose due date falls on `day` (local). For the calendar all-day strip. */
export function todosDueOn(todos: DoctorTodo[], day: Date): DoctorTodo[] {
  const lo = startOfDay(day), hi = endOfDay(day);
  return todos.filter(
    (t) => t.status === 'open' && t.dueAt && isWithinInterval(new Date(t.dueAt), { start: lo, end: hi }),
  );
}
  • Step 4: Run test to verify it passes
Run: cd ui && npx vitest run src/components/todos/lib/bucketTodos.test.ts Expected: PASS (8 tests).
  • Step 5: Commit
git add ui/src/components/todos/lib/bucketTodos.ts ui/src/components/todos/lib/bucketTodos.test.ts
git commit -m "feat(todos): pure bucketTodos logic for planner rail"

Task 2: Shared mapAppointmentsToEvents helper

Extract the exact mapping from AppointmentCalendar.tsx:442-480 into a reusable pure function (the new calendar uses it; AppointmentCalendar is left untouched to avoid risk — consolidating it onto this helper is a future cleanup). Files:
  • Create: ui/src/lib/appointments-to-events.ts
  • Test: ui/src/lib/appointments-to-events.test.ts
  • Step 1: Write the failing test
// ui/src/lib/appointments-to-events.test.ts
import { describe, it, expect } from 'vitest';
import { mapAppointmentsToEvents } from './appointments-to-events';

describe('mapAppointmentsToEvents', () => {
  it('maps date+time+duration into start/end and carries identity fields', () => {
    const [e] = mapAppointmentsToEvents([
      {
        id: 'a1', patientId: 'p1', doctorId: 'd1', appointmentDate: '2026-05-27',
        appointmentTime: '09:30', durationMinutes: 45, status: 'confirmed',
        patientName: 'Ali Khan', doctorName: 'Dr S', appointmentType: 'Scaling',
      } as any,
    ]);
    expect(e.id).toBe('a1');
    expect(e.title).toBe('Ali Khan');
    expect(e.doctorId).toBe('d1');
    expect(e.startDate.getHours()).toBe(9);
    expect(e.startDate.getMinutes()).toBe(30);
    expect((e.endDate.getTime() - e.startDate.getTime()) / 60000).toBe(45);
    expect(e.variant).toBe('primary');
    expect(e.status).toBe('confirmed');
  });

  it('defaults duration to 30 and maps cancelled/completed variants', () => {
    const [c, d] = mapAppointmentsToEvents([
      { id: 'c', appointmentDate: '2026-05-27', appointmentTime: '10:00', status: 'cancelled' } as any,
      { id: 'd', appointmentDate: '2026-05-27', appointmentTime: '11:00', status: 'completed' } as any,
    ]);
    expect((c.endDate.getTime() - c.startDate.getTime()) / 60000).toBe(30);
    expect(c.variant).toBe('danger');
    expect(d.variant).toBe('success');
  });
});
  • Step 2: Run test to verify it fails
Run: cd ui && npx vitest run src/lib/appointments-to-events.test.ts Expected: FAIL — module not found.
  • Step 3: Write the implementation (copied verbatim from AppointmentCalendar.tsx:442-480, minus the highlight wrapper)
// ui/src/lib/appointments-to-events.ts
import type { Event } from '@/types/index';
import type { Appointment } from '@/lib/serverComm';

interface AppointmentWithDetails extends Appointment {
  patient?: { firstName: string; lastName: string };
  doctor?: { firstName: string; lastName: string };
  patientName?: string;
  doctorName?: string;
  patientPhone?: string;
  patientEmail?: string;
}

export function mapAppointmentsToEvents(apts: Appointment[]): Event[] {
  return (apts as AppointmentWithDetails[]).map((apt) => {
    const dateObj = new Date(apt.appointmentDate);
    const [hours, mins] = apt.appointmentTime.split(':').map(Number);
    dateObj.setHours(hours, mins, 0, 0);

    const endDate = new Date(dateObj);
    endDate.setMinutes(dateObj.getMinutes() + (apt.durationMinutes || 30));

    let variant: Event['variant'] = 'primary';
    if (apt.status === 'confirmed') variant = 'primary';
    if (apt.status === 'in_progress') variant = 'warning';
    if (apt.status === 'cancelled') variant = 'danger';
    if (apt.status === 'completed') variant = 'success';
    if (apt.status === 'no_show') variant = 'danger';

    return {
      id: apt.id,
      title: apt.patientName || (apt.patient ? `${apt.patient.firstName} ${apt.patient.lastName}` : 'Unknown Patient'),
      startDate: dateObj,
      endDate,
      variant,
      patientId: apt.patientId,
      patientName: apt.patientName || (apt.patient ? `${apt.patient.firstName} ${apt.patient.lastName}` : 'Unknown Patient'),
      doctorId: apt.doctorId,
      doctorName: apt.doctorName || (apt.doctor ? `${apt.doctor.firstName} ${apt.doctor.lastName}` : 'Unknown Doctor'),
      status: apt.status,
      description: apt.appointmentType,
      notes: apt.notes,
      invoiceId: apt.invoiceId,
      invoiceStatus: apt.invoiceStatus,
      invoiceBalance: apt.invoiceBalance,
      invoiceTotalAmount: apt.invoiceTotalAmount,
      patientPhone: apt.patientPhone,
      patientEmail: apt.patientEmail,
      operatory: apt.operatory,
      roomName: apt.roomName,
      roomColor: apt.roomColor,
    } as Event;
  });
}
  • Step 4: Run test to verify it passes
Run: cd ui && npx vitest run src/lib/appointments-to-events.test.ts Expected: PASS. If TS complains a property doesn’t exist on Appointment/Event, drop that property (match the real types in @/types/index and serverComm); the test only asserts the core fields.
  • Step 5: Commit
git add ui/src/lib/appointments-to-events.ts ui/src/lib/appointments-to-events.test.ts
git commit -m "feat(calendar): extract reusable mapAppointmentsToEvents helper"

Task 3: Additive renderAllDayCell prop on WeekViewV2

The new calendar needs a per-day all-day strip aligned to the grid columns. Add one optional prop; when omitted (the Appointments calendar), the component renders identically to today. Files:
  • Modify: ui/src/components/schedule/_components/view/week/week-view-v2.tsx
  • Step 1: Extend the Props interface (lines 21-27)
Replace:
interface Props {
  currentDate?: Date;
  onDateChange?: (date: Date) => void;
  startHour?: number;
  endHour?: number;
  hideHeader?: boolean;
}
with:
interface Props {
  currentDate?: Date;
  onDateChange?: (date: Date) => void;
  startHour?: number;
  endHour?: number;
  hideHeader?: boolean;
  /**
   * Optional per-day content rendered in an "all-day" row between the day
   * headers and the time grid. When undefined, no row is rendered (default).
   * Used by the Todos planner to show due-todo chips; the Appointments
   * calendar omits it and is therefore unchanged.
   */
  renderAllDayCell?: (day: Date) => React.ReactNode;
}
  • Step 2: Destructure the prop (line 53)
Replace export default function WeekViewV2({ currentDate: propDate, onDateChange, startHour = 9, endHour = 18, hideHeader }: Props) { with export default function WeekViewV2({ currentDate: propDate, onDateChange, startHour = 9, endHour = 18, hideHeader, renderAllDayCell }: Props) {
  • Step 3: Render the all-day row — insert immediately after the day-header visibleDays.map(...) block closes (right after line 120’s })} and before the {/* Hour rail */} comment on line 122)
          {/* All-day row (optional) — grid auto-flow places it between the
              day-header row and the hour-rail/day-columns row. */}
          {renderAllDayCell && (
            <>
              <div className="sticky top-0 z-10 flex items-start justify-end border-b border-r bg-background px-1 pt-1 text-[9px] uppercase tracking-wide text-muted-foreground">
                all-day
              </div>
              {visibleDays.map((day) => (
                <div
                  key={`allday-${localDateStr(day)}`}
                  className="flex min-h-[26px] flex-col gap-1 border-b border-r bg-background px-1 py-1"
                >
                  {renderAllDayCell(day)}
                </div>
              ))}
            </>
          )}
  • Step 4: Typecheck
Run: cd ui && npx tsc --noEmit Expected: no new errors. (React is already imported at line 1; localDateStr at line 9.)
  • Step 5: Visual regression check (Appointments unaffected) — REQUIRED (risk R2)
Run cd ui && npm run dev, open the Appointments calendar, switch to Week view in both light and dark mode. Confirm it looks identical to before (no all-day row, columns aligned). Take screenshots. The Appointments calendar never passes renderAllDayCell, so the new branch must not render.
  • Step 6: Commit
git add ui/src/components/schedule/_components/view/week/week-view-v2.tsx
git commit -m "feat(calendar): optional renderAllDayCell prop on WeekViewV2 (additive)"

Task 4: TodoRailRow component

A compact todo row for the dark rail. Adapts TodoRow (MyTodoListPage.tsx:298-388) to the rail’s tighter layout; reuses Checkbox, Badge, lucide icons. Done/overdue styling preserved. Files:
  • Create: ui/src/components/todos/planner/TodoRailRow.tsx
  • Step 1: Create the component
// ui/src/components/todos/planner/TodoRailRow.tsx
import React from 'react';
import { format, isPast } from 'date-fns';
import { Pin, Archive, Calendar as CalendarIcon, User2, Tag } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { DoctorTodo } from '@/lib/serverComm';

interface Props {
  todo: DoctorTodo;
  isOwn: boolean;
  isAdmin: boolean;
  onOpen: () => void;
  onToggleDone: () => void;
  onTogglePinned: () => void;
  onArchive: () => void;
}

export function TodoRailRow({ todo, isOwn, isAdmin, onOpen, onToggleDone, onTogglePinned, onArchive }: Props) {
  const done = todo.status === 'done';
  const canEdit = isOwn || isAdmin;
  const overdue = todo.status === 'open' && !!todo.dueAt && isPast(new Date(todo.dueAt));
  const title = todo.title?.trim() || (todo.body.length > 80 ? todo.body.slice(0, 80) + '…' : todo.body);

  return (
    <div
      onClick={onOpen}
      className={cn(
        'group flex items-start gap-2.5 rounded-md px-2 py-1.5 cursor-pointer transition-colors',
        'hover:bg-white/[0.06]',
      )}
    >
      <Checkbox
        checked={done}
        disabled={!canEdit}
        onClick={(e) => { e.stopPropagation(); onToggleDone(); }}
        className="mt-0.5 border-white/25 data-[state=checked]:bg-primary"
      />
      <div className="min-w-0 flex-1">
        <p className={cn('truncate text-[13px] text-zinc-100', done && 'text-zinc-500 line-through', overdue && 'text-red-300')}>
          {title}
        </p>
        <div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-[10.5px] text-zinc-400">
          {todo.dueAt && (
            <span className={cn('inline-flex items-center gap-1', overdue && 'text-red-300 font-medium')}>
              <CalendarIcon className="h-2.5 w-2.5" />
              {format(new Date(todo.dueAt), 'd MMM')}
            </span>
          )}
          {todo.assignedTo && (
            <span className="inline-flex items-center gap-1">
              <User2 className="h-2.5 w-2.5" />
              {todo.assignedTo === todo.createdBy ? 'self' : 'assigned'}
            </span>
          )}
          {!isOwn && isAdmin && (
            <Badge variant="outline" className="h-3.5 px-1 text-[9px] border-white/20 text-zinc-300">another doctor</Badge>
          )}
          {todo.tags?.slice(0, 2).map((t) => (
            <span key={t} className="inline-flex items-center gap-0.5 text-violet-300">
              <Tag className="h-2.5 w-2.5" />{t}
            </span>
          ))}
        </div>
      </div>
      {canEdit && (
        <div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
          <Button variant="ghost" size="icon" className="h-5 w-5 text-zinc-400 hover:text-zinc-100"
            onClick={(e) => { e.stopPropagation(); onTogglePinned(); }} title={todo.pinned ? 'Unpin' : 'Pin'}>
            <Pin className={cn('h-3 w-3', todo.pinned && 'fill-current text-primary')} />
          </Button>
          <Button variant="ghost" size="icon" className="h-5 w-5 text-zinc-400 hover:text-red-300"
            onClick={(e) => { e.stopPropagation(); onArchive(); }} title="Archive">
            <Archive className="h-3 w-3" />
          </Button>
        </div>
      )}
    </div>
  );
}
  • Step 2: Typecheck
Run: cd ui && npx tsc --noEmit Expected: no errors.
  • Step 3: Commit
git add ui/src/components/todos/planner/TodoRailRow.tsx
git commit -m "feat(todos): TodoRailRow for planner rail"

Task 5: TodoBucket (collapsible section + quick-add)

Files:
  • Create: ui/src/components/todos/planner/TodoBucket.tsx
  • Step 1: Create the component
// ui/src/components/todos/planner/TodoBucket.tsx
import React, { useState } from 'react';
import { ChevronDown, Plus } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { TodoRailRow } from './TodoRailRow';
import type { DoctorTodo } from '@/lib/serverComm';

interface Props {
  label: string;
  accent?: 'default' | 'danger';
  todos: DoctorTodo[];
  isOwn: (t: DoctorTodo) => boolean;
  isAdmin: boolean;
  defaultOpen?: boolean;
  canQuickAdd?: boolean;
  onOpen: (id: string) => void;
  onToggleDone: (t: DoctorTodo) => void;
  onTogglePinned: (t: DoctorTodo) => void;
  onArchive: (t: DoctorTodo) => void;
  onQuickAdd?: (title: string) => void;
}

export function TodoBucket({
  label, accent = 'default', todos, isOwn, isAdmin, defaultOpen = true,
  canQuickAdd = false, onOpen, onToggleDone, onTogglePinned, onArchive, onQuickAdd,
}: Props) {
  const [open, setOpen] = useState(defaultOpen);
  const [draft, setDraft] = useState('');

  if (todos.length === 0 && !canQuickAdd) return null;

  const submit = () => {
    const v = draft.trim();
    if (!v || !onQuickAdd) return;
    onQuickAdd(v);
    setDraft('');
  };

  return (
    <Collapsible open={open} onOpenChange={setOpen} className="mb-1">
      <CollapsibleTrigger className="flex w-full items-center gap-2 px-2 py-1.5">
        <span className={cn('text-[10.5px] font-semibold uppercase tracking-wider',
          accent === 'danger' ? 'text-red-300' : 'text-violet-300')}>{label}</span>
        <span className="rounded-full bg-white/10 px-1.5 text-[10px] leading-4 text-zinc-400">{todos.length}</span>
        <ChevronDown className={cn('ml-auto h-3.5 w-3.5 text-zinc-500 transition-transform', !open && '-rotate-90')} />
      </CollapsibleTrigger>
      <CollapsibleContent>
        {todos.map((t) => (
          <TodoRailRow
            key={t.id}
            todo={t}
            isOwn={isOwn(t)}
            isAdmin={isAdmin}
            onOpen={() => onOpen(t.id)}
            onToggleDone={() => onToggleDone(t)}
            onTogglePinned={() => onTogglePinned(t)}
            onArchive={() => onArchive(t)}
          />
        ))}
        {canQuickAdd && (
          <div className="flex items-center gap-2 px-2 py-1.5 text-[13px] text-zinc-500">
            <Plus className="h-3.5 w-3.5" />
            <input
              value={draft}
              onChange={(e) => setDraft(e.target.value)}
              onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
              onBlur={submit}
              placeholder="Todo"
              className="w-full bg-transparent text-zinc-200 placeholder:text-zinc-600 outline-none"
            />
          </div>
        )}
      </CollapsibleContent>
    </Collapsible>
  );
}
  • Step 2: Typecheck
Run: cd ui && npx tsc --noEmit Expected: no errors.
  • Step 3: Commit
git add ui/src/components/todos/planner/TodoBucket.tsx
git commit -m "feat(todos): TodoBucket collapsible section with quick-add"

Task 6: TodoRail (compose buckets)

Files:
  • Create: ui/src/components/todos/planner/TodoRail.tsx
  • Step 1: Create the component
// ui/src/components/todos/planner/TodoRail.tsx
import React, { useMemo, useState } from 'react';
import { Plus, Search, MoreHorizontal } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { bucketTodos, bucketDefaultDueIso, type BucketKey } from '../lib/bucketTodos';
import { TodoBucket } from './TodoBucket';
import type { DoctorTodo } from '@/lib/serverComm';

interface Props {
  todos: DoctorTodo[];
  userId: string | undefined;
  isAdmin: boolean;
  onOpen: (id: string) => void;
  onCreate: () => void;
  onToggleDone: (t: DoctorTodo) => void;
  onTogglePinned: (t: DoctorTodo) => void;
  onArchive: (t: DoctorTodo) => void;
  onQuickAdd: (title: string, dueIso: string | null) => void;
}

const OPEN_BUCKETS: Array<{ key: Exclude<BucketKey, 'overdue' | 'done'>; label: string; quickAdd: boolean }> = [
  { key: 'thisWeek', label: 'This week', quickAdd: true },
  { key: 'thisMonth', label: 'This month', quickAdd: true },
  { key: 'later', label: 'Later', quickAdd: true },
  { key: 'noDue', label: 'No due date', quickAdd: true },
];

export function TodoRail({
  todos, userId, isAdmin, onOpen, onCreate, onToggleDone, onTogglePinned, onArchive, onQuickAdd,
}: Props) {
  const [q, setQ] = useState('');

  const visible = useMemo(() => {
    const needle = q.trim().toLowerCase();
    if (!needle) return todos;
    return todos.filter((t) =>
      [t.title, t.body, ...(t.tags ?? [])].filter(Boolean).join(' ').toLowerCase().includes(needle),
    );
  }, [todos, q]);

  const buckets = useMemo(() => bucketTodos(visible, new Date()), [visible]);
  const isOwn = (t: DoctorTodo) => t.createdBy === userId;
  const quickAdd = (key: BucketKey) => (title: string) => onQuickAdd(title, bucketDefaultDueIso(key, new Date()));

  return (
    <div className="flex h-full w-[300px] shrink-0 flex-col bg-zinc-900 text-zinc-100 dark:bg-zinc-950">
      <div className="flex items-center justify-between px-4 pb-2 pt-4">
        <h1 className="text-[17px] font-bold">Todos</h1>
        <div className="flex gap-1.5">
          <button onClick={onCreate} title="New todo"
            className="flex h-7 w-7 items-center justify-center rounded-lg bg-white/[0.08] text-zinc-200 hover:bg-white/[0.14]">
            <Plus className="h-4 w-4" />
          </button>
          <button title="More"
            className="flex h-7 w-7 items-center justify-center rounded-lg bg-white/[0.08] text-zinc-300 hover:bg-white/[0.14]">
            <MoreHorizontal className="h-4 w-4" />
          </button>
        </div>
      </div>

      <div className="mx-4 mb-2 flex items-center gap-2 rounded-lg bg-white/[0.06] px-2.5 py-1.5">
        <Search className="h-3.5 w-3.5 text-zinc-500" />
        <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search todos…"
          className="w-full bg-transparent text-[12px] text-zinc-200 placeholder:text-zinc-500 outline-none" />
      </div>

      <ScrollArea className="flex-1 px-2">
        <TodoBucket label="Overdue" accent="danger" todos={buckets.overdue} isOwn={isOwn} isAdmin={isAdmin}
          onOpen={onOpen} onToggleDone={onToggleDone} onTogglePinned={onTogglePinned} onArchive={onArchive} />
        {OPEN_BUCKETS.map((b) => (
          <TodoBucket key={b.key} label={b.label} todos={buckets[b.key]} isOwn={isOwn} isAdmin={isAdmin}
            canQuickAdd={b.quickAdd} onOpen={onOpen} onToggleDone={onToggleDone} onTogglePinned={onTogglePinned}
            onArchive={onArchive} onQuickAdd={quickAdd(b.key)} />
        ))}
        {buckets.done.length > 0 && (
          <TodoBucket label="Done" todos={buckets.done} isOwn={isOwn} isAdmin={isAdmin} defaultOpen={false}
            onOpen={onOpen} onToggleDone={onToggleDone} onTogglePinned={onTogglePinned} onArchive={onArchive} />
        )}
      </ScrollArea>
    </div>
  );
}
  • Step 2: Typecheck
Run: cd ui && npx tsc --noEmit Expected: no errors.
  • Step 3: Commit
git add ui/src/components/todos/planner/TodoRail.tsx
git commit -m "feat(todos): TodoRail composing due-date buckets"

Task 7: TodoWeekCalendar (right pane)

Fetches the visible week’s appointments, maps them with mapAppointmentsToEvents, seeds a SchedulerProvider, and renders WeekViewV2 with the all-day chip strip. For doctors, pre-filters events to their own appointments so the calendar reflects their schedule; admins see all. Files:
  • Create: ui/src/components/todos/planner/TodoWeekCalendar.tsx
  • Step 1: Create the component
// ui/src/components/todos/planner/TodoWeekCalendar.tsx
import React, { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { startOfWeek, endOfWeek, addDays, getISOWeek, format } from 'date-fns';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { SchedulerProvider } from '@/providers/schedular-provider';
import WeekViewV2 from '@/components/schedule/_components/view/week/week-view-v2';
import { getAppointments } from '@/lib/serverComm';
import { qk } from '@/lib/queryKeys';
import { mapAppointmentsToEvents } from '@/lib/appointments-to-events';
import { todosDueOn } from '../lib/bucketTodos';
import { localDateStr } from '@/lib/date-range';
import { cn } from '@/lib/utils';
import type { DoctorTodo } from '@/lib/serverComm';

const WEEK_OPTS = { weekStartsOn: 1 as const };

interface Props {
  weekDate: Date;
  onWeekChange: (d: Date) => void;
  onToday: () => void;
  todos: DoctorTodo[];
  onOpenTodo: (id: string) => void;
  /** When set, only this doctor's appointments are shown (doctor's own schedule). */
  doctorId?: string;
}

export function TodoWeekCalendar({ weekDate, onWeekChange, onToday, todos, onOpenTodo, doctorId }: Props) {
  const weekStart = useMemo(() => startOfWeek(weekDate, WEEK_OPTS), [weekDate]);
  const weekEnd = useMemo(() => endOfWeek(weekDate, WEEK_OPTS), [weekDate]);
  const startStr = localDateStr(weekStart);
  const endStr = localDateStr(weekEnd);

  const apptsQuery = useQuery({
    queryKey: qk.appointments.list({ scope: 'todos-planner', view: 'week', start: startStr, end: endStr }),
    queryFn: () => getAppointments(startStr, endStr),
    staleTime: 30_000,
    meta: { noPersist: true },
  });

  const events = useMemo(() => {
    const all = mapAppointmentsToEvents(apptsQuery.data ?? []);
    return doctorId ? all.filter((e) => e.doctorId === doctorId) : all;
  }, [apptsQuery.data, doctorId]);

  // Fit the grid to the week's appointments (fallback to clinic-standard 9–18).
  const { startHour, endHour } = useMemo(() => {
    if (events.length === 0) return { startHour: 9, endHour: 18 };
    let lo = 9, hi = 18;
    for (const e of events) {
      lo = Math.min(lo, e.startDate.getHours());
      hi = Math.max(hi, e.endDate.getHours() + (e.endDate.getMinutes() > 0 ? 1 : 0));
    }
    return { startHour: Math.max(0, lo), endHour: Math.min(24, Math.max(lo + 1, hi)) };
  }, [events]);

  const renderAllDayCell = (day: Date) =>
    todosDueOn(todos, day).map((t) => (
      <button
        key={t.id}
        onClick={() => onOpenTodo(t.id)}
        className="flex w-full items-center gap-1 truncate rounded border-l-2 border-violet-400 bg-violet-100 px-1.5 py-0.5 text-left text-[10px] text-violet-800 hover:bg-violet-200 dark:bg-violet-500/15 dark:text-violet-200"
        title={t.title?.trim() || t.body}
      >
        <span className="truncate">{t.title?.trim() || t.body}</span>
      </button>
    ));

  return (
    <div className="flex h-full min-w-0 flex-1 flex-col bg-background">
      <div className="flex items-center gap-3 border-b px-4 py-3">
        <span className="text-[16px] font-bold">{format(weekStart, 'MMMM yyyy')}</span>
        <span className="text-[12px] font-medium text-muted-foreground">/ W{getISOWeek(weekDate)}</span>
        <div className="ml-1 flex gap-1">
          <button onClick={() => onWeekChange(addDays(weekStart, -7))}
            className="flex h-6 w-6 items-center justify-center rounded-md border hover:bg-muted">
            <ChevronLeft className="h-3.5 w-3.5" />
          </button>
          <button onClick={() => onWeekChange(addDays(weekStart, 7))}
            className="flex h-6 w-6 items-center justify-center rounded-md border hover:bg-muted">
            <ChevronRight className="h-3.5 w-3.5" />
          </button>
        </div>
        <button onClick={onToday} className="ml-auto rounded-md border px-3 py-1 text-[12px] font-medium hover:bg-muted">
          Today
        </button>
      </div>
      <div className={cn('min-h-0 flex-1', apptsQuery.isLoading && 'opacity-60')}>
        <SchedulerProvider
          initialState={events}
          weekStartsOn="monday"
          startHour={startHour}
          endHour={endHour}
          operatingHours={null}
          doctorSchedules={[]}
          currentDate={weekDate}
        >
          <WeekViewV2
            currentDate={weekDate}
            startHour={startHour}
            endHour={endHour}
            hideHeader
            renderAllDayCell={renderAllDayCell}
          />
        </SchedulerProvider>
      </div>
    </div>
  );
}
Note: SchedulerProvider’s initialState seeds events once; because weekDate/events change the component re-renders and the provider re-seeds on initialState change (as in AppointmentCalendar, where events state drives it). If events don’t refresh on week change during the visual pass, set a key={startStr} on <SchedulerProvider> to force a clean re-seed per week.
  • Step 2: Typecheck
Run: cd ui && npx tsc --noEmit Expected: no errors. If qk.appointments.list rejects the scope: 'todos-planner' literal, widen by using the same shape the calendar uses or qk.appointments.all() as the key.
  • Step 3: Commit
git add ui/src/components/todos/planner/TodoWeekCalendar.tsx
git commit -m "feat(todos): TodoWeekCalendar reusing WeekViewV2 with todo chips"

Task 8: TodoPlannerPage (orchestrator)

Keeps MyTodoListPage’s data layer verbatim; swaps the list body for the two-pane layout; adds ?date week state; collapses to rail-only with a “Calendar” toggle below lg. Files:
  • Create: ui/src/components/todos/TodoPlannerPage.tsx
  • Step 1: Create the component
// ui/src/components/todos/TodoPlannerPage.tsx
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { CalendarDays, ListTodo } from 'lucide-react';
import { useAuth } from '@/lib/auth-context';
import { useDeviceType } from '@/components/ui/use-mobile';
import {
  listDoctorTodos, createDoctorTodo, updateDoctorTodo, deleteDoctorTodo, type DoctorTodo,
} from '@/lib/serverComm';
import TodoFormPage, { type TodoFormValues } from './TodoFormPage';
import { TodoRail } from './planner/TodoRail';
import { TodoWeekCalendar } from './planner/TodoWeekCalendar';
import { cn } from '@/lib/utils';

const TODOS_KEY = ['doctor-todos'] as const;

export default function TodoPlannerPage() {
  const { user } = useAuth();
  const isAdmin = user?.role === 'admin' || user?.role === 'superadmin';
  const queryClient = useQueryClient();

  const [searchParams, setSearchParams] = useSearchParams();
  const action = searchParams.get('action');
  const editingId = searchParams.get('id') || null;
  const showForm = action === 'new' || !!editingId;
  const dateParam = searchParams.get('date');
  const weekDate = useMemo(() => {
    if (dateParam) { const d = new Date(dateParam); if (!isNaN(d.getTime())) return d; }
    return new Date();
  }, [dateParam]);

  const patchUrl = (mut: (sp: URLSearchParams) => void) =>
    setSearchParams((prev) => { const next = new URLSearchParams(prev); mut(next); return next; });
  const openCreate = () => patchUrl((sp) => { sp.set('action', 'new'); sp.delete('id'); });
  const openEdit = (id: string) => patchUrl((sp) => { sp.set('id', id); sp.delete('action'); });
  const closeForm = () => patchUrl((sp) => { sp.delete('action'); sp.delete('id'); });
  const setWeek = (d: Date) => patchUrl((sp) => { sp.set('date', d.toISOString().slice(0, 10)); });
  const goToday = () => patchUrl((sp) => { sp.delete('date'); });

  const todosQuery = useQuery({
    queryKey: TODOS_KEY,
    queryFn: () => listDoctorTodos({ status: 'all', limit: 200 }),
    staleTime: 15_000,
  });
  const todos: DoctorTodo[] = todosQuery.data ?? [];

  const createMut = useMutation({
    mutationFn: (vals: TodoFormValues) => createDoctorTodo(vals),
    onSuccess: () => { queryClient.invalidateQueries({ queryKey: TODOS_KEY }); toast.success('To-Do created'); },
    onError: (err: Error) => toast.error(err.message || 'Failed to create'),
  });
  const updateMut = useMutation({
    mutationFn: ({ id, patch }: { id: string; patch: Parameters<typeof updateDoctorTodo>[1] }) => updateDoctorTodo(id, patch),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: TODOS_KEY }),
    onError: (err: Error) => toast.error(err.message || 'Update failed'),
  });
  const deleteMut = useMutation({
    mutationFn: (id: string) => deleteDoctorTodo(id),
    onSuccess: () => { queryClient.invalidateQueries({ queryKey: TODOS_KEY }); toast.success('Archived'); },
    onError: (err: Error) => toast.error(err.message || 'Archive failed'),
  });
  const quickAddMut = useMutation({
    mutationFn: ({ body, dueAt }: { body: string; dueAt: string | null }) =>
      createDoctorTodo({ body, dueAt: dueAt ?? undefined }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: TODOS_KEY }),
    onError: (err: Error) => toast.error(err.message || 'Failed to add'),
  });

  const editing = useMemo(() => (editingId ? todos.find((t) => t.id === editingId) ?? null : null), [editingId, todos]);

  const handleFormSubmit = async (vals: TodoFormValues) => {
    if (editing) { await updateMut.mutateAsync({ id: editing.id, patch: vals }); toast.success('To-Do updated'); }
    else { await createMut.mutateAsync(vals); }
  };

  if (showForm) {
    return (
      <TodoFormPage
        initial={editing ?? undefined}
        busy={createMut.isPending || updateMut.isPending}
        onBack={closeForm}
        onSubmit={handleFormSubmit}
      />
    );
  }

  const { isMobile, isTablet } = useDeviceType();
  const narrow = isMobile || isTablet;
  const [mobilePane, setMobilePane] = useState<'list' | 'calendar'>('list');
  const calendarDoctorId = user?.role === 'doctor' ? user.id : undefined;

  const rail = (
    <TodoRail
      todos={todos}
      userId={user?.id}
      isAdmin={isAdmin}
      onOpen={openEdit}
      onCreate={openCreate}
      onToggleDone={(t) => updateMut.mutate({ id: t.id, patch: { status: t.status === 'done' ? 'open' : 'done' } })}
      onTogglePinned={(t) => updateMut.mutate({ id: t.id, patch: { pinned: !t.pinned } })}
      onArchive={(t) => deleteMut.mutate(t.id)}
      onQuickAdd={(title, dueIso) => quickAddMut.mutate({ body: title, dueAt: dueIso })}
    />
  );
  const calendar = (
    <TodoWeekCalendar
      weekDate={weekDate}
      onWeekChange={setWeek}
      onToday={goToday}
      todos={todos}
      onOpenTodo={openEdit}
      doctorId={calendarDoctorId}
    />
  );

  if (narrow) {
    return (
      <div className="flex h-full flex-col">
        <div className="flex shrink-0 gap-1 border-b bg-zinc-900 p-2">
          <button onClick={() => setMobilePane('list')}
            className={cn('flex flex-1 items-center justify-center gap-1.5 rounded-md py-1.5 text-[12px] font-medium',
              mobilePane === 'list' ? 'bg-white/15 text-white' : 'text-zinc-400')}>
            <ListTodo className="h-4 w-4" /> Todos
          </button>
          <button onClick={() => setMobilePane('calendar')}
            className={cn('flex flex-1 items-center justify-center gap-1.5 rounded-md py-1.5 text-[12px] font-medium',
              mobilePane === 'calendar' ? 'bg-white/15 text-white' : 'text-zinc-400')}>
            <CalendarDays className="h-4 w-4" /> Calendar
          </button>
        </div>
        <div className="min-h-0 flex-1">
          {mobilePane === 'list'
            ? <div className="h-full w-full [&>div]:w-full">{rail}</div>
            : calendar}
        </div>
      </div>
    );
  }

  return (
    <div className="flex h-full w-full overflow-hidden">
      {rail}
      {calendar}
    </div>
  );
}
Note: useDeviceType is called after the early showForm return. Move both useDeviceType() and useState calls above the if (showForm) return to satisfy the rules-of-hooks (hooks must run unconditionally). Place them right after editing/handleFormSubmit and before the if (showForm) block.
  • Step 2: Fix hook ordering — move const { isMobile, isTablet } = useDeviceType(); and const [mobilePane, setMobilePane] = useState<'list'|'calendar'>('list'); to above if (showForm).
  • Step 3: Typecheck
Run: cd ui && npx tsc --noEmit Expected: no errors. Confirm createDoctorTodo({ body, dueAt }) matches its signature (body required, dueAt?: string|null). Confirm useDeviceType is exported from @/components/ui/use-mobile (used by WeekViewV2).
  • Step 4: Commit
git add ui/src/components/todos/TodoPlannerPage.tsx
git commit -m "feat(todos): TodoPlannerPage two-pane orchestrator"

Task 9: Wire into dashboards + nav-registry

Files:
  • Modify: ui/src/components/dashboards/DoctorDashboard.tsx (line 42 import, line 233 render)
  • Modify: ui/src/components/dashboards/AdminDashboard.tsx (line 40 import, line 301 render)
  • Modify: ui/src/lib/nav-registry.ts (MODULE_PARAMS)
  • Step 1: Swap the lazy import in both dashboards
In DoctorDashboard.tsx replace:
const MyTodoListPage = lazy(() => import('@/components/todos/MyTodoListPage'));
with:
const TodoPlannerPage = lazy(() => import('@/components/todos/TodoPlannerPage'));
And replace the render line:
{activeView === 'todos' && <ModuleErrorBoundary moduleName="My To-Do List"><MyTodoListPage /></ModuleErrorBoundary>}
with:
{activeView === 'todos' && <ModuleErrorBoundary moduleName="Todos"><TodoPlannerPage /></ModuleErrorBoundary>}
Apply the identical two edits in AdminDashboard.tsx (import line 40, render line 301).
  • Step 2: Register todos params in nav-registry.ts — add inside MODULE_PARAMS (e.g. after the appointments line):
  todos:                ['date', 'q', 'action', 'id'],
  • Step 3: Typecheck + build
Run: cd ui && npx tsc --noEmit && npm run build Expected: clean typecheck; build succeeds.
  • Step 4: Commit
git add ui/src/components/dashboards/DoctorDashboard.tsx ui/src/components/dashboards/AdminDashboard.tsx ui/src/lib/nav-registry.ts
git commit -m "feat(todos): route Todos view to new TodoPlannerPage; register date param"

Task 10: Verification & visual pass (REQUIRED)

Files: none (verification only).
  • Step 1: Full typecheck, tests, build
Run: cd ui && npx tsc --noEmit && npx vitest run src/components/todos/lib/bucketTodos.test.ts src/lib/appointments-to-events.test.ts && npm run build Expected: all pass.
  • Step 2: Run the app and verify (light + dark)
Run cd ui && npm run dev, sign in to the canonical test tenant “ssh & Associates” (clinic b6d3a3f3-…). Navigate to ?view=todos. Verify:
  • Dark rail shows buckets (Overdue/This week/This month/Later/No due date) with counts; pinned float to top; overdue is red.
  • Checkbox toggles done (moves to Done footer); pin/archive hover actions work.
  • + Todo quick-add in a bucket creates a todo with the right due date and it lands in that bucket.
  • Clicking a row opens TodoFormPage; back returns to the planner.
  • Right pane shows the week’s appointments (doctor sees only their own; admin sees all), correct week header Month YYYY / W##, prev/next/Today work and update ?date.
  • Due-todo chips appear in the correct day’s all-day strip and open the form on click.
  • Resize below lg: rail-only with a working Todos/Calendar toggle. Capture screenshots (light + dark).
  • Step 3: Appointments regression check (risk R2)
Open the Appointments calendar → Week view (light + dark). Confirm it is visually unchanged (no all-day row). Capture a screenshot.
  • Step 4: Legacy URL check
Visit /doctor/todos → confirm it still redirects to /dashboard?view=todos and renders the planner.
  • Step 5 (optional cleanup): remove the old page once parity confirmed
If MyTodoListPage has no remaining importers (grep -rn "MyTodoListPage" ui/src), delete ui/src/components/todos/MyTodoListPage.tsx. Keep TodoFormPage, TodoVoiceRecorder, AppointmentTodoPanel. Commit separately:
git rm ui/src/components/todos/MyTodoListPage.tsx
git commit -m "chore(todos): remove legacy MyTodoListPage after planner parity"

Self-review notes

  • Spec coverage: layout (Tasks 6–8), calendar reuse + read-only (Task 7; week view has no drag), all-day chips (Tasks 3+7), bucketing PKT/local + Monday week (Task 1), access unchanged (Task 8 keeps useAuth/isAdmin; doctor calendar pre-filter is an additive relevance improvement, not an access change), responsive (Task 8), URL/MODULE_PARAMS (Tasks 8–9), no schema change (R1 resolved: quick-add sends body), regression guard (Tasks 3/10). All covered.
  • R1 (body required): resolved — quickAddMut and the chip path send body, never a bare title.
  • R3 (timezone): bucketing uses local date interpretation, matching the existing isPast/format display; documented assumption (PKT users, noon-UTC dueAt).
  • R5 (read-only calendar): week view is inherently non-mutating (drag is day-view only; click dispatches a global event no one listens to in this module).
  • Type consistency: BucketKey, bucketTodos, bucketDefaultDueIso, todosDueOn, mapAppointmentsToEvents, TodoRailRow/TodoBucket/TodoRail/TodoWeekCalendar props are consistent across tasks.
  • Hooks: Task 8 Step 2 fixes conditional-hook ordering explicitly.