Skip to main content

Calendar v2 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: Ship spacious, crash-safe v2 calendar views (day/week/month) alongside the legacy ones, with the appointments-pagination data bug fixed, in one PR with a query-flag rollback. Architecture: New v2 files (day-view-v2.tsx, week-view-v2.tsx, month-view-v2.tsx) live next to the legacy ones; schedular-view-filteration.tsx picks v2 by default and falls back to legacy on ?calendar=legacy. Shared primitives (EventCard, OverflowPill, UtilizationBar, UtilizationRing, HourHeatStrip, NowLine) and a useDayIndex hook keep per-render work O(1) per cell. Server limit cap bumped to 2000; client requests limit=2000 and recursively splits the range if it ever returns full. Tech Stack: React 19, TypeScript, Tailwind, framer-motion (already in use), vitest (existing test runner), Hono + Drizzle on the server, Zod for query validation. Spec: docs/superpowers/specs/2026-05-21-calendar-v2-design.md

Task 1: Bump server appointments query cap

Files:
  • Modify: server/src/routes/appointments.ts:64-71
  • Test: server/src/routes/__tests__/appointments-limit.test.ts (new)
  • Step 1: Write the failing test
Create server/src/routes/__tests__/appointments-limit.test.ts:
import { describe, it, expect } from 'vitest';
import { z } from 'zod';

// Mirror the schema in routes/appointments.ts so the test fails when it drifts.
const appointmentQuerySchema = z.object({
  startDate: z.string().date().optional(),
  endDate: z.string().date().optional(),
  limit: z.coerce.number().min(1).max(2000).optional().default(500),
  offset: z.coerce.number().min(0).optional().default(0),
  doctorId: z.string().uuid().optional(),
});

describe('appointmentQuerySchema (mirror)', () => {
  it('defaults limit to 500 when omitted', () => {
    const parsed = appointmentQuerySchema.parse({});
    expect(parsed.limit).toBe(500);
  });

  it('accepts limit up to 2000', () => {
    const parsed = appointmentQuerySchema.parse({ limit: 2000 });
    expect(parsed.limit).toBe(2000);
  });

  it('rejects limit above 2000', () => {
    expect(() => appointmentQuerySchema.parse({ limit: 2001 })).toThrow();
  });
});
  • Step 2: Run test, confirm it does NOT fail (it’s a mirror — the test ensures we have a regression catch if the schema drifts).
Run: cd server && pnpm vitest run src/routes/__tests__/appointments-limit.test.ts Expected: PASS — this test exists to lock the contract; we now update the real schema to match.
  • Step 3: Update the real schema
Edit server/src/routes/appointments.ts:64-71. Replace:
// Query schema for filtering appointments
const appointmentQuerySchema = z.object({
  startDate: z.string().date().optional(),
  endDate: z.string().date().optional(),
  limit: z.coerce.number().min(1).max(100).optional().default(50),
  offset: z.coerce.number().min(0).optional().default(0),
  doctorId: z.string().uuid().optional(),
});
With:
// Query schema for filtering appointments.
// Cap raised from 100 → 2000 so Week/Month calendar views (which fetch up to ~75 days
// at a time) stop dropping the visible window when a clinic does >50 appts/day.
// Default raised 50 → 500 so unmodified callers (older mobile builds) get a sane window.
const appointmentQuerySchema = z.object({
  startDate: z.string().date().optional(),
  endDate: z.string().date().optional(),
  limit: z.coerce.number().min(1).max(2000).optional().default(500),
  offset: z.coerce.number().min(0).optional().default(0),
  doctorId: z.string().uuid().optional(),
});
  • Step 4: Run the server tests in this file’s neighborhood
Run: cd server && pnpm vitest run src/routes/__tests__/appointments Expected: PASS (no other appointment tests should regress).
  • Step 5: Commit
git add server/src/routes/appointments.ts server/src/routes/__tests__/appointments-limit.test.ts
git commit -m "fix(appointments): raise query cap 100→2000, default 50→500"

Task 2: Add localDateStr + addDaysToDateStr + midpointDate helpers in shared lib

Files:
  • Create: ui/src/lib/date-range.ts
  • Test: ui/src/lib/date-range.test.ts
  • Step 1: Write the failing test
Create ui/src/lib/date-range.test.ts:
import { describe, it, expect } from 'vitest';
import { addDaysToDateStr, midpointDateStr, isValidDateStr } from './date-range';

describe('date-range helpers', () => {
  it('addDaysToDateStr adds days across month boundary', () => {
    expect(addDaysToDateStr('2026-05-30', 3)).toBe('2026-06-02');
  });
  it('addDaysToDateStr supports negative offsets', () => {
    expect(addDaysToDateStr('2026-05-01', -1)).toBe('2026-04-30');
  });
  it('midpointDateStr returns the integer midpoint', () => {
    expect(midpointDateStr('2026-05-01', '2026-05-11')).toBe('2026-05-06');
  });
  it('midpointDateStr returns null when start >= end', () => {
    expect(midpointDateStr('2026-05-10', '2026-05-10')).toBeNull();
    expect(midpointDateStr('2026-05-10', '2026-05-09')).toBeNull();
  });
  it('isValidDateStr rejects malformed input', () => {
    expect(isValidDateStr('2026-05-21')).toBe(true);
    expect(isValidDateStr('2026-13-01')).toBe(false);
    expect(isValidDateStr('not-a-date')).toBe(false);
  });
});
  • Step 2: Run test to verify it fails
Run: cd ui && pnpm vitest run src/lib/date-range.test.ts Expected: FAIL — module not found.
  • Step 3: Write the implementation
Create ui/src/lib/date-range.ts:
// Pure YYYY-MM-DD string arithmetic, no timezone footguns from Date objects.
// All calendar range math runs through this module so envelopes stay stable
// regardless of the user's local TZ.

const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;

export function isValidDateStr(s: string): boolean {
  if (!ISO_DATE_RE.test(s)) return false;
  const [y, m, d] = s.split('-').map(Number);
  if (m < 1 || m > 12 || d < 1 || d > 31) return false;
  const dt = new Date(Date.UTC(y, m - 1, d));
  return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
}

export function addDaysToDateStr(s: string, days: number): string {
  const [y, m, d] = s.split('-').map(Number);
  const dt = new Date(Date.UTC(y, m - 1, d));
  dt.setUTCDate(dt.getUTCDate() + days);
  const yy = dt.getUTCFullYear().toString().padStart(4, '0');
  const mm = (dt.getUTCMonth() + 1).toString().padStart(2, '0');
  const dd = dt.getUTCDate().toString().padStart(2, '0');
  return `${yy}-${mm}-${dd}`;
}

export function daysBetween(start: string, end: string): number {
  const [y1, m1, d1] = start.split('-').map(Number);
  const [y2, m2, d2] = end.split('-').map(Number);
  const a = Date.UTC(y1, m1 - 1, d1);
  const b = Date.UTC(y2, m2 - 1, d2);
  return Math.round((b - a) / 86400000);
}

export function midpointDateStr(start: string, end: string): string | null {
  const span = daysBetween(start, end);
  if (span <= 0) return null;
  return addDaysToDateStr(start, Math.floor(span / 2));
}

/** YYYY-MM-DD for any Date in local TZ. */
export function localDateStr(date: Date): string {
  const yy = date.getFullYear().toString().padStart(4, '0');
  const mm = (date.getMonth() + 1).toString().padStart(2, '0');
  const dd = date.getDate().toString().padStart(2, '0');
  return `${yy}-${mm}-${dd}`;
}
  • Step 4: Run test to verify it passes
Run: cd ui && pnpm vitest run src/lib/date-range.test.ts Expected: PASS — all 5 cases green.
  • Step 5: Commit
git add ui/src/lib/date-range.ts ui/src/lib/date-range.test.ts
git commit -m "feat(ui): add date-range string helpers for TZ-safe envelope math"

Task 3: Update getAppointments to pass limit=2000 + recursive cap-overflow safety net

Files:
  • Modify: ui/src/lib/serverComm.ts:1996-2002
  • Test: ui/src/lib/getAppointments.test.ts (new)
  • Step 1: Write the failing test
Create ui/src/lib/getAppointments.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest';

const fetchWithAuthMock = vi.fn();
vi.mock('./serverComm', async (importOriginal) => {
  const actual: any = await importOriginal();
  return {
    ...actual,
    // Override only the internal fetch helper — keep getAppointments real.
    fetchWithAuth: fetchWithAuthMock,
  };
});

import { getAppointments } from './serverComm';

function makeRow(id: string, date: string) {
  return { id, appointmentDate: date, appointmentTime: '09:00' };
}

beforeEach(() => {
  fetchWithAuthMock.mockReset();
});

describe('getAppointments', () => {
  it('passes limit=2000 in the URL', async () => {
    fetchWithAuthMock.mockResolvedValue({
      ok: true,
      json: async () => ({ data: [makeRow('a', '2026-05-21')] }),
    });
    await getAppointments('2026-05-20', '2026-05-22');
    const url = fetchWithAuthMock.mock.calls[0][0] as string;
    expect(url).toContain('startDate=2026-05-20');
    expect(url).toContain('endDate=2026-05-22');
    expect(url).toContain('limit=2000');
  });

  it('returns the data array on a normal call', async () => {
    fetchWithAuthMock.mockResolvedValue({
      ok: true,
      json: async () => ({ data: [makeRow('a', '2026-05-21')] }),
    });
    const result = await getAppointments('2026-05-20', '2026-05-22');
    expect(result.map((r: any) => r.id)).toEqual(['a']);
  });

  it('splits the range and dedupes when API returns exactly 2000 rows', async () => {
    const fullPage = Array.from({ length: 2000 }, (_, i) => makeRow(`e${i}`, '2026-05-21'));
    fetchWithAuthMock
      .mockResolvedValueOnce({ ok: true, json: async () => ({ data: fullPage }) })
      .mockResolvedValueOnce({ ok: true, json: async () => ({ data: [makeRow('e0', '2026-05-21'), makeRow('left-extra', '2026-05-21')] }) })
      .mockResolvedValueOnce({ ok: true, json: async () => ({ data: [makeRow('right-extra', '2026-05-22')] }) });
    const result = await getAppointments('2026-05-20', '2026-05-22');
    // 2000 from initial + 1 new from left half + 1 from right half = 2002 unique
    const ids = new Set(result.map((r: any) => r.id));
    expect(ids.has('left-extra')).toBe(true);
    expect(ids.has('right-extra')).toBe(true);
    expect(result.length).toBe(ids.size); // no duplicates
  });

  it('does NOT split when start === end even at cap', async () => {
    const fullPage = Array.from({ length: 2000 }, (_, i) => makeRow(`e${i}`, '2026-05-21'));
    fetchWithAuthMock.mockResolvedValueOnce({ ok: true, json: async () => ({ data: fullPage }) });
    const result = await getAppointments('2026-05-21', '2026-05-21');
    expect(result.length).toBe(2000);
    expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
  });
});
  • Step 2: Run test to verify it fails
Run: cd ui && pnpm vitest run src/lib/getAppointments.test.ts Expected: FAIL — at least the “passes limit=2000” assertion fails since the URL currently has no limit param.
  • Step 3: Update getAppointments
Edit ui/src/lib/serverComm.ts:1996-2002. Replace:
export async function getAppointments(startDate: string, endDate: string): Promise<Appointment[]> {
  const response = await fetchWithAuth(
    `/api/v1/protected/appointments?startDate=${startDate}&endDate=${endDate}`
  );
  const result = await response.json();
  return result.data || [];
}
With:
import { midpointDateStr, addDaysToDateStr } from './date-range';

/**
 * Fetch appointments in a date range. Calendar surfaces fan this out for
 * windows up to ~75 days (month view). The server caps per-request rows at
 * 2000; if a window ever hits the cap, we split the range in half and recurse
 * — bounded by O(log2(rangeDays)) ≤ 7 calls for any realistic envelope.
 */
export async function getAppointments(startDate: string, endDate: string): Promise<Appointment[]> {
  const LIMIT = 2000;
  const response = await fetchWithAuth(
    `/api/v1/protected/appointments?startDate=${startDate}&endDate=${endDate}&limit=${LIMIT}`
  );
  const result = await response.json();
  const rows: Appointment[] = result.data || [];

  if (rows.length < LIMIT) return rows;
  if (startDate === endDate) return rows; // can't split a single day; accept truncation

  const mid = midpointDateStr(startDate, endDate);
  if (!mid || mid === startDate) return rows;

  const [left, right] = await Promise.all([
    getAppointments(startDate, mid),
    getAppointments(addDaysToDateStr(mid, 1), endDate),
  ]);

  // Dedupe by id — recursion windows can overlap at the boundary day.
  const seen = new Set<string>();
  const out: Appointment[] = [];
  for (const a of [...left, ...right]) {
    if (seen.has(a.id)) continue;
    seen.add(a.id);
    out.push(a);
  }
  return out;
}
Move the existing getAppointments import block: at the top of serverComm.ts make sure midpointDateStr and addDaysToDateStr from ./date-range are imported.
  • Step 4: Run test to verify it passes
Run: cd ui && pnpm vitest run src/lib/getAppointments.test.ts Expected: PASS — all 4 cases green.
  • Step 5: Sanity-check the wider type check
Run: cd ui && pnpm tsc --noEmit Expected: no new type errors introduced by the edit.
  • Step 6: Commit
git add ui/src/lib/serverComm.ts ui/src/lib/getAppointments.test.ts
git commit -m "fix(calendar): getAppointments passes limit=2000 with recursive split safety net"

Task 4: Add useDayIndex hook

Files:
  • Create: ui/src/hooks/use-day-index.ts
  • Test: ui/src/hooks/use-day-index.test.tsx
  • Step 1: Write the failing test
Create ui/src/hooks/use-day-index.test.tsx:
import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useDayIndex } from './use-day-index';
import type { Event } from '@/types';

function ev(id: string, dateIso: string, hour: number, doctorId?: string, status?: string): Event {
  const start = new Date(dateIso);
  start.setHours(hour, 0, 0, 0);
  const end = new Date(start.getTime() + 30 * 60_000);
  return {
    id, title: id, startDate: start, endDate: end,
    variant: 'primary', doctorId, status: status as any,
  } as Event;
}

describe('useDayIndex', () => {
  it('groups events by YYYY-MM-DD', () => {
    const events = [ev('a', '2026-05-21', 9), ev('b', '2026-05-21', 14), ev('c', '2026-05-22', 9)];
    const { result } = renderHook(() => useDayIndex(events, null, null));
    expect(result.current.byDate.get('2026-05-21')?.length).toBe(2);
    expect(result.current.byDate.get('2026-05-22')?.length).toBe(1);
  });

  it('counts events per hour per day', () => {
    const events = [ev('a', '2026-05-21', 9), ev('b', '2026-05-21', 9), ev('c', '2026-05-21', 14)];
    const { result } = renderHook(() => useDayIndex(events, null, null));
    expect(result.current.byDateByHour.get('2026-05-21')?.get(9)).toBe(2);
    expect(result.current.byDateByHour.get('2026-05-21')?.get(14)).toBe(1);
  });

  it('respects doctor filter', () => {
    const events = [ev('a', '2026-05-21', 9, 'd1'), ev('b', '2026-05-21', 9, 'd2')];
    const { result } = renderHook(() => useDayIndex(events, 'd1', null));
    expect(result.current.byDate.get('2026-05-21')?.map(e => e.id)).toEqual(['a']);
  });

  it('respects status filter', () => {
    const events = [ev('a', '2026-05-21', 9, undefined, 'confirmed'), ev('b', '2026-05-21', 9, undefined, 'cancelled')];
    const { result } = renderHook(() => useDayIndex(events, null, 'confirmed'));
    expect(result.current.byDate.get('2026-05-21')?.map(e => e.id)).toEqual(['a']);
  });

  it('returns stable identity when inputs are referentially equal', () => {
    const events = [ev('a', '2026-05-21', 9)];
    const { result, rerender } = renderHook(({ e }) => useDayIndex(e, null, null), { initialProps: { e: events } });
    const first = result.current;
    rerender({ e: events });
    expect(result.current).toBe(first);
  });
});
  • Step 2: Run test, confirm failure
Run: cd ui && pnpm vitest run src/hooks/use-day-index.test.tsx Expected: FAIL — module not found.
  • Step 3: Write the implementation
Create ui/src/hooks/use-day-index.ts:
import { useMemo } from 'react';
import type { Event } from '@/types';
import { localDateStr } from '@/lib/date-range';

export interface DayIndex {
  /** YYYY-MM-DD → events on that local-TZ day, sorted by start time. */
  byDate: Map<string, Event[]>;
  /** YYYY-MM-DD → (0-23 hour → count of events whose START hour matches). */
  byDateByHour: Map<string, Map<number, number>>;
}

const EMPTY_INDEX: DayIndex = {
  byDate: new Map(),
  byDateByHour: new Map(),
};

export function useDayIndex(
  events: Event[] | undefined,
  filterDoctorId: string | null,
  filterStatus: string | null,
): DayIndex {
  return useMemo(() => {
    if (!events || events.length === 0) return EMPTY_INDEX;

    const byDate = new Map<string, Event[]>();
    const byDateByHour = new Map<string, Map<number, number>>();

    for (const event of events) {
      if (filterDoctorId && event.doctorId && event.doctorId !== filterDoctorId) continue;
      if (filterStatus && (event.status || 'scheduled') !== filterStatus) continue;

      const start = event.startDate instanceof Date ? event.startDate : new Date(event.startDate);
      if (isNaN(start.getTime())) continue;

      const key = localDateStr(start);
      let bucket = byDate.get(key);
      if (!bucket) { bucket = []; byDate.set(key, bucket); }
      bucket.push(event);

      let hourMap = byDateByHour.get(key);
      if (!hourMap) { hourMap = new Map(); byDateByHour.set(key, hourMap); }
      const hour = start.getHours();
      hourMap.set(hour, (hourMap.get(hour) || 0) + 1);
    }

    // Sort each bucket by start time once
    for (const bucket of byDate.values()) {
      bucket.sort((a, b) => {
        const aT = (a.startDate instanceof Date ? a.startDate : new Date(a.startDate)).getTime();
        const bT = (b.startDate instanceof Date ? b.startDate : new Date(b.startDate)).getTime();
        return aT - bT;
      });
    }

    return { byDate, byDateByHour };
  }, [events, filterDoctorId, filterStatus]);
}

/** Module-level frozen empty array used to keep referential equality across renders. */
export const EMPTY_EVENTS: readonly Event[] = Object.freeze([]) as readonly Event[];
  • Step 4: Run test to verify it passes
Run: cd ui && pnpm vitest run src/hooks/use-day-index.test.tsx Expected: PASS — all 5 cases green.
  • Step 5: Commit
git add ui/src/hooks/use-day-index.ts ui/src/hooks/use-day-index.test.tsx
git commit -m "feat(calendar): useDayIndex hook — O(1) per-day lookups for calendar views"

Task 5: Add status-styling constants

Files:
  • Create: ui/src/components/schedule/_components/v2/status-style.ts
  • Test: ui/src/components/schedule/_components/v2/status-style.test.ts
  • Step 1: Write the failing test
Create ui/src/components/schedule/_components/v2/status-style.test.ts:
import { describe, it, expect } from 'vitest';
import { statusToClasses, statusToDot } from './status-style';

describe('statusToClasses', () => {
  it('returns a ribbon + tint class for known statuses', () => {
    expect(statusToClasses('confirmed').ribbon).toMatch(/emerald/);
    expect(statusToClasses('cancelled').ribbon).toMatch(/rose/);
    expect(statusToClasses('completed').ribbon).toMatch(/slate/);
    expect(statusToClasses('no_show').ribbon).toMatch(/orange/);
    expect(statusToClasses('in_progress').ribbon).toMatch(/violet/);
    expect(statusToClasses('requested').ribbon).toMatch(/amber/);
    expect(statusToClasses('scheduled').ribbon).toMatch(/blue/);
  });
  it('falls back to scheduled style for unknown', () => {
    const s = statusToClasses('unknown' as any);
    expect(s.ribbon).toMatch(/blue/);
  });
  it('statusToDot returns a dot color class', () => {
    expect(statusToDot('confirmed')).toMatch(/emerald/);
  });
});
  • Step 2: Run test, confirm failure
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/status-style.test.ts Expected: FAIL — module not found.
  • Step 3: Write the implementation
Create ui/src/components/schedule/_components/v2/status-style.ts:
export type AppointmentStatus =
  | 'scheduled' | 'confirmed' | 'in_progress' | 'completed'
  | 'cancelled' | 'no_show' | 'requested';

export interface StatusClasses {
  /** 4px left ribbon background. */
  ribbon: string;
  /** Card surface tint (very subtle). */
  surface: string;
  /** Text accent (for time/status pill). */
  accent: string;
  /** Border for hover/selection. */
  border: string;
}

const TABLE: Record<AppointmentStatus, StatusClasses> = {
  scheduled:   { ribbon: 'bg-blue-500',     surface: 'bg-blue-500/5',     accent: 'text-blue-700 dark:text-blue-300',     border: 'border-blue-500/30'  },
  confirmed:   { ribbon: 'bg-emerald-500',  surface: 'bg-emerald-500/5',  accent: 'text-emerald-700 dark:text-emerald-300', border: 'border-emerald-500/30'},
  in_progress: { ribbon: 'bg-violet-500',   surface: 'bg-violet-500/5',   accent: 'text-violet-700 dark:text-violet-300',   border: 'border-violet-500/30'},
  completed:   { ribbon: 'bg-slate-500',    surface: 'bg-slate-500/5',    accent: 'text-slate-700 dark:text-slate-300',     border: 'border-slate-500/30' },
  cancelled:   { ribbon: 'bg-rose-500',     surface: 'bg-rose-500/5',     accent: 'text-rose-700 dark:text-rose-300',       border: 'border-rose-500/30'  },
  no_show:     { ribbon: 'bg-orange-500',   surface: 'bg-orange-500/5',   accent: 'text-orange-700 dark:text-orange-300',   border: 'border-orange-500/30'},
  requested:   { ribbon: 'bg-amber-500',    surface: 'bg-amber-500/5',    accent: 'text-amber-700 dark:text-amber-300',     border: 'border-amber-500/30' },
};

export function statusToClasses(s: AppointmentStatus | string | undefined): StatusClasses {
  return TABLE[(s as AppointmentStatus) || 'scheduled'] ?? TABLE.scheduled;
}

export function statusToDot(s: AppointmentStatus | string | undefined): string {
  return statusToClasses(s).ribbon;
}
  • Step 4: Run test to verify it passes
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/status-style.test.ts Expected: PASS — all 3 cases green.
  • Step 5: Commit
git add ui/src/components/schedule/_components/v2/status-style.ts ui/src/components/schedule/_components/v2/status-style.test.ts
git commit -m "feat(calendar): status-style — accessible status hue table for v2 components"

Task 6: EventCard primitive

Files:
  • Create: ui/src/components/schedule/_components/v2/EventCard.tsx
  • Test: ui/src/components/schedule/_components/v2/EventCard.test.tsx
  • Step 1: Write the failing test
Create ui/src/components/schedule/_components/v2/EventCard.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EventCard } from './EventCard';
import type { Event } from '@/types';

function ev(over: Partial<Event> = {}): Event {
  const start = new Date('2026-05-21T09:00:00');
  const end = new Date('2026-05-21T09:30:00');
  return {
    id: 'e1', title: 'Smith, Jane', startDate: start, endDate: end,
    variant: 'primary', patientName: 'Smith, Jane', doctorName: 'Dr. Khan',
    status: 'confirmed' as any, roomName: 'Op 1',
    ...over,
  } as Event;
}

describe('EventCard', () => {
  it('renders patient name and time', () => {
    render(<EventCard event={ev()} />);
    expect(screen.getByText('Smith, Jane')).toBeInTheDocument();
    expect(screen.getByText(/09:00/)).toBeInTheDocument();
  });
  it('renders doctor chip on normal/wide width', () => {
    render(<EventCard event={ev()} width="normal" />);
    expect(screen.getByText(/Khan/)).toBeInTheDocument();
  });
  it('hides doctor chip on narrow width', () => {
    render(<EventCard event={ev()} width="narrow" />);
    expect(screen.queryByText(/Khan/)).toBeNull();
  });
  it('renders compact variant on one line', () => {
    const { container } = render(<EventCard event={ev()} variant="compact" />);
    expect(container.textContent).toContain('09:00');
    expect(container.textContent).toContain('Smith, Jane');
  });
  it('applies confirmed status ribbon class', () => {
    const { container } = render(<EventCard event={ev()} />);
    expect(container.querySelector('[data-status-ribbon]')?.className).toMatch(/emerald/);
  });
});
  • Step 2: Run test, confirm failure
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/EventCard.test.tsx Expected: FAIL — module not found.
  • Step 3: Write the implementation
Create ui/src/components/schedule/_components/v2/EventCard.tsx:
import React from 'react';
import clsx from 'clsx';
import type { Event } from '@/types';
import { statusToClasses } from './status-style';
import { useClinicTimeFormat } from '@/lib/useClinicTimeFormat';
import { formatClockTime } from '@/lib/datetime';

export interface EventCardProps {
  event: Event;
  variant?: 'normal' | 'compact';
  /** Controls which meta lines are shown. */
  width?: 'narrow' | 'normal' | 'wide';
  onClick?: (event: Event) => void;
  isSelected?: boolean;
  className?: string;
}

function initialsOf(name: string | undefined): string {
  if (!name) return '?';
  const parts = name.trim().split(/[,\s]+/).filter(Boolean);
  if (parts.length === 0) return '?';
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0][0] + parts[1][0]).toUpperCase();
}

export function EventCard({
  event,
  variant = 'normal',
  width = 'normal',
  onClick,
  isSelected = false,
  className,
}: EventCardProps) {
  const timeFormat = useClinicTimeFormat();
  const start = event.startDate instanceof Date ? event.startDate : new Date(event.startDate);
  const end = event.endDate instanceof Date ? event.endDate : new Date(event.endDate);
  const status = (event.status as string) || 'scheduled';
  const s = statusToClasses(status);
  const name = (event.patientName || event.title || '').toString();
  const startStr = formatClockTime(start, timeFormat);
  const endStr = formatClockTime(end, timeFormat);

  const handleClick = (e: React.MouseEvent) => {
    e.stopPropagation();
    onClick?.(event);
  };

  if (variant === 'compact') {
    return (
      <button
        type="button"
        onClick={handleClick}
        title={`${startStr}${name}`}
        className={clsx(
          'group flex w-full items-center gap-2 rounded-md border-l-2 bg-card px-2 py-1 text-left text-xs',
          'transition-colors hover:bg-muted/50',
          s.border,
          status === 'cancelled' && 'line-through opacity-70',
          isSelected && 'ring-2 ring-primary',
          className,
        )}
      >
        <span data-status-ribbon className={clsx('h-2 w-2 shrink-0 rounded-full', s.ribbon)} />
        <span className="font-medium tabular-nums opacity-80">{startStr}</span>
        <span className="truncate">{name}</span>
      </button>
    );
  }

  return (
    <button
      type="button"
      onClick={handleClick}
      title={`${startStr}${endStr}${name}`}
      className={clsx(
        'group relative flex h-full w-full flex-col overflow-hidden rounded-md border bg-card text-left',
        'shadow-sm transition-all duration-150 hover:shadow-md',
        s.border,
        status === 'cancelled' && 'opacity-70',
        isSelected && 'ring-2 ring-primary',
        className,
      )}
    >
      <span data-status-ribbon className={clsx('absolute inset-y-0 left-0 w-1', s.ribbon)} />
      <div className="flex h-full flex-col gap-0.5 px-2 py-1.5 pl-3">
        <div className="flex items-center gap-1.5">
          <span className={clsx('text-xs font-semibold tabular-nums', s.accent)}>
            {startStr}<span className="text-muted-foreground"></span>{endStr}
          </span>
        </div>
        <div className={clsx('truncate text-sm font-semibold', status === 'cancelled' && 'line-through')}>
          {name}
        </div>
        {(width !== 'narrow') && event.doctorName && (
          <div className="truncate text-[11px] text-muted-foreground">{event.doctorName}</div>
        )}
        {width === 'wide' && event.roomName && (
          <div className="mt-auto truncate text-[10px] text-muted-foreground/80">{event.roomName}</div>
        )}
      </div>
      {width === 'wide' && (
        <div className="absolute right-2 top-1.5 grid h-6 w-6 place-items-center rounded-full bg-muted text-[10px] font-semibold text-muted-foreground">
          {initialsOf(name)}
        </div>
      )}
    </button>
  );
}
  • Step 4: Run test to verify it passes
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/EventCard.test.tsx Expected: PASS.
  • Step 5: Commit
git add ui/src/components/schedule/_components/v2/EventCard.tsx ui/src/components/schedule/_components/v2/EventCard.test.tsx
git commit -m "feat(calendar): EventCard v2 — spacious card with status ribbon + compact variant"

Task 7: UtilizationBar and UtilizationRing primitives

Files:
  • Create: ui/src/components/schedule/_components/v2/Utilization.tsx
  • Test: ui/src/components/schedule/_components/v2/Utilization.test.tsx
  • Step 1: Write the failing test
Create ui/src/components/schedule/_components/v2/Utilization.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UtilizationBar, UtilizationRing } from './Utilization';

describe('Utilization components', () => {
  it('UtilizationBar shows aria-valuenow', () => {
    render(<UtilizationBar bookedMinutes={30} operatingMinutes={60} count={2} />);
    const bar = screen.getByRole('progressbar');
    expect(bar).toHaveAttribute('aria-valuenow', '50');
    expect(bar).toHaveAttribute('aria-valuemax', '100');
  });
  it('UtilizationBar caps at 100%', () => {
    render(<UtilizationBar bookedMinutes={120} operatingMinutes={60} count={5} />);
    expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100');
  });
  it('UtilizationRing renders count inside when showCount', () => {
    render(<UtilizationRing bookedMinutes={30} operatingMinutes={60} count={7} showCount />);
    expect(screen.getByText('7')).toBeInTheDocument();
  });
  it('returns null elements when operatingMinutes is 0', () => {
    const { container } = render(<UtilizationBar bookedMinutes={0} operatingMinutes={0} count={0} />);
    expect(container.firstChild).toBeNull();
  });
});
  • Step 2: Run test, confirm failure
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/Utilization.test.tsx Expected: FAIL — module not found.
  • Step 3: Write the implementation
Create ui/src/components/schedule/_components/v2/Utilization.tsx:
import React from 'react';
import clsx from 'clsx';

export interface UtilizationProps {
  bookedMinutes: number;
  operatingMinutes: number;
  count: number;
  className?: string;
}

export interface UtilizationRingProps extends UtilizationProps {
  showCount?: boolean;
  size?: number;
}

function percent(bookedMinutes: number, operatingMinutes: number): number {
  if (operatingMinutes <= 0) return 0;
  const raw = (bookedMinutes / operatingMinutes) * 100;
  return Math.max(0, Math.min(100, Math.round(raw)));
}

function hueClass(pct: number, target: 'bar' | 'ring'): string {
  if (pct < 30) return target === 'bar' ? 'bg-emerald-500' : 'stroke-emerald-500';
  if (pct < 70) return target === 'bar' ? 'bg-amber-500'    : 'stroke-amber-500';
  return target === 'bar' ? 'bg-rose-500' : 'stroke-rose-500';
}

export function UtilizationBar({ bookedMinutes, operatingMinutes, count, className }: UtilizationProps) {
  if (operatingMinutes <= 0) return null;
  const pct = percent(bookedMinutes, operatingMinutes);
  return (
    <div
      role="progressbar"
      aria-valuenow={pct}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-label={`${count} appointments, ${pct}% booked`}
      className={clsx('relative h-1 w-full overflow-hidden rounded-full bg-muted/40', className)}
    >
      <div className={clsx('h-full rounded-full transition-all duration-300', hueClass(pct, 'bar'))} style={{ width: `${pct}%` }} />
    </div>
  );
}

export function UtilizationRing({
  bookedMinutes, operatingMinutes, count,
  showCount = false, size = 24, className,
}: UtilizationRingProps) {
  if (operatingMinutes <= 0 && count === 0) return null;
  const pct = percent(bookedMinutes, operatingMinutes);
  const stroke = 2.5;
  const radius = (size - stroke) / 2;
  const circumference = 2 * Math.PI * radius;
  const dash = (pct / 100) * circumference;
  return (
    <div
      role="progressbar"
      aria-valuenow={pct}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-label={`${count} appointments, ${pct}% booked`}
      className={clsx('relative inline-flex items-center justify-center', className)}
      style={{ width: size, height: size }}
    >
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="-rotate-90">
        <circle cx={size / 2} cy={size / 2} r={radius} fill="none" strokeWidth={stroke} className="stroke-muted/40" />
        <circle
          cx={size / 2} cy={size / 2} r={radius} fill="none" strokeWidth={stroke}
          strokeLinecap="round" strokeDasharray={`${dash} ${circumference}`}
          className={clsx(hueClass(pct, 'ring'), 'transition-all duration-300')}
        />
      </svg>
      {showCount && (
        <span className="absolute text-[10px] font-semibold tabular-nums">{count}</span>
      )}
    </div>
  );
}
  • Step 4: Run test to verify it passes
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/Utilization.test.tsx Expected: PASS.
  • Step 5: Commit
git add ui/src/components/schedule/_components/v2/Utilization.tsx ui/src/components/schedule/_components/v2/Utilization.test.tsx
git commit -m "feat(calendar): UtilizationBar + UtilizationRing primitives"

Task 8: HourHeatStrip primitive

Files:
  • Create: ui/src/components/schedule/_components/v2/HourHeatStrip.tsx
  • Test: ui/src/components/schedule/_components/v2/HourHeatStrip.test.tsx
  • Step 1: Write the failing test
Create ui/src/components/schedule/_components/v2/HourHeatStrip.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { HourHeatStrip } from './HourHeatStrip';

describe('HourHeatStrip', () => {
  it('renders one bar per hour in the window', () => {
    const byHour = new Map<number, number>([[10, 3]]);
    const { container } = render(<HourHeatStrip byHour={byHour} windowStart={9} windowEnd={18} />);
    expect(container.querySelectorAll('[data-heat-bar]').length).toBe(9);
  });
  it('labels each bar with hour and count', () => {
    const byHour = new Map<number, number>([[10, 3]]);
    render(<HourHeatStrip byHour={byHour} windowStart={9} windowEnd={11} />);
    expect(screen.getByLabelText(/10:00, 3 appointments/)).toBeInTheDocument();
    expect(screen.getByLabelText(/9:00, 0 appointments/)).toBeInTheDocument();
  });
  it('renders nothing when window is empty', () => {
    const { container } = render(<HourHeatStrip byHour={undefined} windowStart={9} windowEnd={9} />);
    expect(container.firstChild).toBeNull();
  });
});
  • Step 2: Run test, confirm failure
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/HourHeatStrip.test.tsx Expected: FAIL — module not found.
  • Step 3: Write the implementation
Create ui/src/components/schedule/_components/v2/HourHeatStrip.tsx:
import React, { useMemo } from 'react';
import clsx from 'clsx';

export interface HourHeatStripProps {
  byHour: Map<number, number> | undefined;
  windowStart: number;
  windowEnd: number;
  className?: string;
}

/**
 * Renders one thin vertical bar per hour in [windowStart, windowEnd).
 * Intensity scales against the max count in the window so a day always looks
 * "hot" relative to itself — useful for shape-recognition across months.
 */
export function HourHeatStrip({ byHour, windowStart, windowEnd, className }: HourHeatStripProps) {
  const hours = useMemo(() => {
    const out: number[] = [];
    for (let h = windowStart; h < windowEnd; h++) out.push(h);
    return out;
  }, [windowStart, windowEnd]);

  const max = useMemo(() => {
    if (!byHour) return 0;
    let m = 0;
    for (const v of byHour.values()) if (v > m) m = v;
    return m;
  }, [byHour]);

  if (hours.length === 0) return null;

  return (
    <div className={clsx('flex h-6 w-full items-end gap-[2px]', className)}>
      {hours.map(h => {
        const count = byHour?.get(h) ?? 0;
        const intensity = max > 0 ? count / max : 0;
        const height = max === 0 ? 4 : Math.max(2, Math.round(intensity * 22));
        const tint =
          intensity === 0 ? 'bg-muted/40' :
          intensity < 0.34 ? 'bg-emerald-500/60' :
          intensity < 0.67 ? 'bg-amber-500/70' :
          'bg-rose-500/80';
        return (
          <div
            key={h}
            data-heat-bar
            role="img"
            aria-label={`${h}:00, ${count} appointments`}
            className={clsx('flex-1 rounded-sm transition-all', tint)}
            style={{ height: `${height}px` }}
          />
        );
      })}
    </div>
  );
}
  • Step 4: Run test to verify it passes
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/HourHeatStrip.test.tsx Expected: PASS.
  • Step 5: Commit
git add ui/src/components/schedule/_components/v2/HourHeatStrip.tsx ui/src/components/schedule/_components/v2/HourHeatStrip.test.tsx
git commit -m "feat(calendar): HourHeatStrip primitive for month-cell density"

Task 9: OverflowPill primitive (with popover)

Files:
  • Create: ui/src/components/schedule/_components/v2/OverflowPill.tsx
  • Test: ui/src/components/schedule/_components/v2/OverflowPill.test.tsx
  • Step 1: Write the failing test
Create ui/src/components/schedule/_components/v2/OverflowPill.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { OverflowPill } from './OverflowPill';
import type { Event } from '@/types';

function ev(id: string, hour: number): Event {
  const s = new Date('2026-05-21T00:00:00'); s.setHours(hour, 0, 0, 0);
  const e = new Date(s.getTime() + 30 * 60_000);
  return { id, title: id, startDate: s, endDate: e, variant: 'primary' } as Event;
}

describe('OverflowPill', () => {
  it('renders count and band', () => {
    render(<OverflowPill events={[ev('a', 10), ev('b', 10)]} band={{ startHour: 10, endHour: 11 }} />);
    expect(screen.getByText(/\+2/)).toBeInTheDocument();
    expect(screen.getByText(/10:00/)).toBeInTheDocument();
  });
  it('opens a popover with the events on click', () => {
    render(<OverflowPill events={[ev('a', 10), ev('b', 10)]} band={{ startHour: 10, endHour: 11 }} />);
    fireEvent.click(screen.getByRole('button'));
    expect(screen.getByRole('dialog')).toBeInTheDocument();
    expect(screen.getAllByText(/^a$|^b$/)).toHaveLength(2);
  });
});
  • Step 2: Run test, confirm failure
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/OverflowPill.test.tsx Expected: FAIL — module not found.
  • Step 3: Write the implementation
Create ui/src/components/schedule/_components/v2/OverflowPill.tsx:
import React, { useState, useCallback } from 'react';
import clsx from 'clsx';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { Event } from '@/types';
import { EventCard } from './EventCard';

export interface OverflowPillProps {
  events: Event[];
  band: { startHour: number; endHour: number };
  onSelect?: (event: Event) => void;
  className?: string;
}

function fmtHour(h: number): string {
  return `${h.toString().padStart(2, '0')}:00`;
}

export function OverflowPill({ events, band, onSelect, className }: OverflowPillProps) {
  const [open, setOpen] = useState(false);

  const handleSelect = useCallback((event: Event) => {
    setOpen(false);
    onSelect?.(event);
  }, [onSelect]);

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <button
          type="button"
          className={clsx(
            'inline-flex items-center gap-1 rounded-full border bg-card px-2 py-0.5 text-[11px] font-medium',
            'shadow-sm transition-colors hover:bg-muted/60',
            className,
          )}
          aria-label={`+${events.length} appointments at ${fmtHour(band.startHour)}`}
        >
          <span className="font-semibold">+{events.length}</span>
          <span className="text-muted-foreground">at {fmtHour(band.startHour)}</span>
        </button>
      </PopoverTrigger>
      <PopoverContent role="dialog" align="start" className="w-72 p-2">
        <div className="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
          {fmtHour(band.startHour)}{fmtHour(band.endHour)} · {events.length} appointments
        </div>
        <div className="max-h-80 space-y-1 overflow-y-auto">
          {events.map(ev => (
            <EventCard key={ev.id} event={ev} variant="compact" onClick={handleSelect} />
          ))}
        </div>
      </PopoverContent>
    </Popover>
  );
}
  • Step 4: Verify shadcn popover component exists
Run: ls ui/src/components/ui/popover.tsx Expected: file exists. (If it doesn’t, skip ahead to Task 9b below.)
  • Step 4b (only if popover.tsx is missing): scaffold shadcn popover
Run: cd ui && npx shadcn@latest add popover --yes Verify: ui/src/components/ui/popover.tsx now exists.
  • Step 5: Run test to verify it passes
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/OverflowPill.test.tsx Expected: PASS.
  • Step 6: Commit
git add ui/src/components/schedule/_components/v2/OverflowPill.tsx ui/src/components/schedule/_components/v2/OverflowPill.test.tsx ui/src/components/ui/popover.tsx
git commit -m "feat(calendar): OverflowPill — '+N at HH:00' popover for dense bands"

Task 10: NowLine primitive

Files:
  • Create: ui/src/components/schedule/_components/v2/NowLine.tsx
  • Test: ui/src/components/schedule/_components/v2/NowLine.test.tsx
  • Step 1: Write the failing test
Create ui/src/components/schedule/_components/v2/NowLine.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { NowLine } from './NowLine';

describe('NowLine', () => {
  it('places the line at the correct top for a known time', () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-05-21T10:30:00'));
    render(<NowLine startHour={9} endHour={18} pxPerHour={120} />);
    const line = screen.getByRole('presentation');
    // 90 minutes after start at 120px/hour → 180px
    expect((line as HTMLElement).style.top).toBe('180px');
    vi.useRealTimers();
  });
  it('renders nothing outside the visible window', () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-05-21T05:00:00'));
    const { container } = render(<NowLine startHour={9} endHour={18} pxPerHour={120} />);
    expect(container.firstChild).toBeNull();
    vi.useRealTimers();
  });
});
  • Step 2: Run test, confirm failure
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/NowLine.test.tsx Expected: FAIL — module not found.
  • Step 3: Write the implementation
Create ui/src/components/schedule/_components/v2/NowLine.tsx:
import React, { useState, useEffect } from 'react';

export interface NowLineProps {
  startHour: number;
  endHour: number;
  pxPerHour: number;
}

export function NowLine({ startHour, endHour, pxPerHour }: NowLineProps) {
  const [now, setNow] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 60_000);
    return () => clearInterval(id);
  }, []);

  const hour = now.getHours();
  const minute = now.getMinutes();
  if (hour < startHour || hour >= endHour) return null;

  const top = (hour - startHour) * pxPerHour + (minute / 60) * pxPerHour;
  const label = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;

  return (
    <div
      role="presentation"
      className="pointer-events-none absolute left-0 right-0 z-30 h-px bg-primary/80"
      style={{ top: `${top}px` }}
    >
      <span className="absolute -left-1 -top-2.5 inline-flex items-center rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary-foreground shadow-sm">
        {label}
      </span>
    </div>
  );
}
  • Step 4: Run test to verify it passes
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2/NowLine.test.tsx Expected: PASS.
  • Step 5: Commit
git add ui/src/components/schedule/_components/v2/NowLine.tsx ui/src/components/schedule/_components/v2/NowLine.test.tsx
git commit -m "feat(calendar): NowLine primitive — minute-ticking timeline indicator"

Task 11: useOperatingMinutes helper hook

Files:
  • Create: ui/src/hooks/use-operating-minutes.ts
  • Test: ui/src/hooks/use-operating-minutes.test.ts
  • Step 1: Write the failing test
Create ui/src/hooks/use-operating-minutes.test.ts:
import { describe, it, expect } from 'vitest';
import { operatingMinutesFor, bookedMinutesFor } from './use-operating-minutes';
import type { Event } from '@/types';

function ev(startIso: string, endIso: string): Event {
  return { id: startIso, title: 't', startDate: new Date(startIso), endDate: new Date(endIso), variant: 'primary' } as Event;
}

describe('operatingMinutesFor', () => {
  it('uses operatingHours when enabled', () => {
    const hours: any = { monday: { enabled: true, open: '09:00', close: '17:00' } };
    expect(operatingMinutesFor(new Date('2026-05-25T00:00:00'), hours)).toBe(8 * 60);
  });
  it('returns 0 when day is disabled', () => {
    const hours: any = { sunday: { enabled: false } };
    expect(operatingMinutesFor(new Date('2026-05-24T00:00:00'), hours)).toBe(0);
  });
  it('falls back to 8h default when missing', () => {
    expect(operatingMinutesFor(new Date('2026-05-21T00:00:00'), null)).toBe(8 * 60);
  });
});

describe('bookedMinutesFor', () => {
  it('sums event durations', () => {
    const list = [
      ev('2026-05-21T09:00:00', '2026-05-21T09:30:00'),
      ev('2026-05-21T10:00:00', '2026-05-21T11:00:00'),
    ];
    expect(bookedMinutesFor(list)).toBe(90);
  });
  it('returns 0 for empty list', () => {
    expect(bookedMinutesFor([])).toBe(0);
  });
});
  • Step 2: Run test, confirm failure
Run: cd ui && pnpm vitest run src/hooks/use-operating-minutes.test.ts Expected: FAIL — module not found.
  • Step 3: Write the implementation
Create ui/src/hooks/use-operating-minutes.ts:
import type { Event } from '@/types';

const DAY_KEYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const;

interface DayConfig {
  enabled: boolean;
  open?: string;
  close?: string;
}

type OperatingHours = Partial<Record<typeof DAY_KEYS[number], DayConfig>> | null | undefined;

function timeToMinutes(t: string | undefined): number | null {
  if (!t || !/^\d{1,2}:\d{2}$/.test(t)) return null;
  const [hh, mm] = t.split(':').map(Number);
  return hh * 60 + mm;
}

export function operatingMinutesFor(day: Date, hours: OperatingHours): number {
  const key = DAY_KEYS[day.getDay()];
  const cfg = hours?.[key];
  if (cfg && cfg.enabled === false) return 0;
  const open = timeToMinutes(cfg?.open);
  const close = timeToMinutes(cfg?.close);
  if (open != null && close != null && close > open) return close - open;
  return 8 * 60; // sensible default
}

export function bookedMinutesFor(events: readonly Event[]): number {
  let total = 0;
  for (const ev of events) {
    const start = ev.startDate instanceof Date ? ev.startDate : new Date(ev.startDate);
    const end = ev.endDate instanceof Date ? ev.endDate : new Date(ev.endDate);
    if (isNaN(start.getTime()) || isNaN(end.getTime())) continue;
    const mins = Math.max(0, Math.round((end.getTime() - start.getTime()) / 60_000));
    total += mins;
  }
  return total;
}
  • Step 4: Run test to verify it passes
Run: cd ui && pnpm vitest run src/hooks/use-operating-minutes.test.ts Expected: PASS.
  • Step 5: Commit
git add ui/src/hooks/use-operating-minutes.ts ui/src/hooks/use-operating-minutes.test.ts
git commit -m "feat(calendar): operating + booked minutes helpers for utilization UI"

Task 12: useDayLayout hook (overlap clustering, pure)

Files:
  • Create: ui/src/hooks/use-day-layout.ts
  • Test: ui/src/hooks/use-day-layout.test.ts
  • Step 1: Write the failing test
Create ui/src/hooks/use-day-layout.test.ts:
import { describe, it, expect } from 'vitest';
import { layoutDay } from './use-day-layout';
import type { Event } from '@/types';

function ev(id: string, startIso: string, endIso: string): Event {
  return { id, title: id, startDate: new Date(startIso), endDate: new Date(endIso), variant: 'primary' } as Event;
}

describe('layoutDay', () => {
  it('returns one card per non-overlapping event', () => {
    const events = [ev('a', '2026-05-21T09:00', '2026-05-21T09:30'), ev('b', '2026-05-21T10:00', '2026-05-21T10:30')];
    const layout = layoutDay(events);
    expect(layout.cards.length).toBe(2);
    expect(layout.overflows.length).toBe(0);
  });
  it('places overlapping pair side-by-side', () => {
    const events = [ev('a', '2026-05-21T09:00', '2026-05-21T09:30'), ev('b', '2026-05-21T09:15', '2026-05-21T09:45')];
    const layout = layoutDay(events);
    expect(layout.cards.length).toBe(2);
    expect(layout.cards[0].column).toBe(0);
    expect(layout.cards[1].column).toBe(1);
    expect(layout.cards[0].totalColumns).toBe(2);
  });
  it('collapses 4+ overlapping events into an overflow pill', () => {
    const events = [
      ev('a', '2026-05-21T09:00', '2026-05-21T10:00'),
      ev('b', '2026-05-21T09:10', '2026-05-21T10:00'),
      ev('c', '2026-05-21T09:20', '2026-05-21T10:00'),
      ev('d', '2026-05-21T09:30', '2026-05-21T10:00'),
      ev('e', '2026-05-21T09:40', '2026-05-21T10:00'),
    ];
    const layout = layoutDay(events);
    expect(layout.cards.length).toBe(2);
    expect(layout.overflows.length).toBe(1);
    expect(layout.overflows[0].events.map(e => e.id).sort()).toEqual(['c', 'd', 'e']);
    expect(layout.overflows[0].band.startHour).toBe(9);
  });
});
  • Step 2: Run test, confirm failure
Run: cd ui && pnpm vitest run src/hooks/use-day-layout.test.ts Expected: FAIL — module not found.
  • Step 3: Write the implementation
Create ui/src/hooks/use-day-layout.ts:
import { useMemo } from 'react';
import type { Event } from '@/types';

export interface LaidOutCard {
  event: Event;
  column: number;
  totalColumns: number;
  startMinutes: number;  // minutes from midnight
  endMinutes: number;
}

export interface OverflowGroup {
  events: Event[];
  band: { startHour: number; endHour: number };
}

export interface DayLayout {
  cards: LaidOutCard[];
  overflows: OverflowGroup[];
}

const MAX_VISIBLE_PER_CLUSTER = 2; // 4+ collapses past index 1

function minutesOf(d: Date | string): number {
  const dt = d instanceof Date ? d : new Date(d);
  return dt.getHours() * 60 + dt.getMinutes();
}

function overlaps(a: Event, b: Event): boolean {
  const aS = (a.startDate instanceof Date ? a.startDate : new Date(a.startDate)).getTime();
  const aE = (a.endDate instanceof Date ? a.endDate : new Date(a.endDate)).getTime();
  const bS = (b.startDate instanceof Date ? b.startDate : new Date(b.startDate)).getTime();
  const bE = (b.endDate instanceof Date ? b.endDate : new Date(b.endDate)).getTime();
  return aS < bE && bS < aE;
}

/** Pure: cluster overlapping events, allocate columns, emit overflow groups beyond MAX_VISIBLE_PER_CLUSTER. */
export function layoutDay(events: readonly Event[]): DayLayout {
  if (!events || events.length === 0) return { cards: [], overflows: [] };

  const sorted = [...events].sort((a, b) => {
    const aT = (a.startDate instanceof Date ? a.startDate : new Date(a.startDate)).getTime();
    const bT = (b.startDate instanceof Date ? b.startDate : new Date(b.startDate)).getTime();
    return aT - bT;
  });

  // Build clusters via simple sweep (because list is sorted by start, a cluster
  // continues while any active event's end > new event's start).
  const clusters: Event[][] = [];
  let current: Event[] = [];
  let currentMaxEnd = 0;
  for (const ev of sorted) {
    const startT = (ev.startDate instanceof Date ? ev.startDate : new Date(ev.startDate)).getTime();
    if (current.length === 0 || startT < currentMaxEnd) {
      current.push(ev);
      const endT = (ev.endDate instanceof Date ? ev.endDate : new Date(ev.endDate)).getTime();
      currentMaxEnd = Math.max(currentMaxEnd, endT);
    } else {
      clusters.push(current);
      current = [ev];
      currentMaxEnd = (ev.endDate instanceof Date ? ev.endDate : new Date(ev.endDate)).getTime();
    }
  }
  if (current.length) clusters.push(current);

  const cards: LaidOutCard[] = [];
  const overflows: OverflowGroup[] = [];

  for (const cluster of clusters) {
    // Assign columns via greedy interval-graph packing inside the cluster.
    const columns: Event[][] = []; // each column holds non-overlapping events
    for (const ev of cluster) {
      let placed = false;
      for (let c = 0; c < columns.length; c++) {
        const last = columns[c][columns[c].length - 1];
        if (!overlaps(last, ev)) { columns[c].push(ev); placed = true; break; }
      }
      if (!placed) columns.push([ev]);
    }
    const totalColumns = columns.length;

    if (totalColumns <= 3) {
      // Render every event as a column-positioned card.
      columns.forEach((col, idx) => {
        for (const ev of col) {
          cards.push({
            event: ev, column: idx, totalColumns,
            startMinutes: minutesOf(ev.startDate),
            endMinutes: minutesOf(ev.endDate),
          });
        }
      });
    } else {
      // 4+ overlapping columns — only the first MAX_VISIBLE_PER_CLUSTER are kept as cards.
      const visibleColumns = columns.slice(0, MAX_VISIBLE_PER_CLUSTER);
      const hiddenColumns = columns.slice(MAX_VISIBLE_PER_CLUSTER);
      const visibleTotal = MAX_VISIBLE_PER_CLUSTER + 1; // +1 for the pill itself

      visibleColumns.forEach((col, idx) => {
        for (const ev of col) {
          cards.push({
            event: ev, column: idx, totalColumns: visibleTotal,
            startMinutes: minutesOf(ev.startDate),
            endMinutes: minutesOf(ev.endDate),
          });
        }
      });
      const hiddenEvents = hiddenColumns.flat();
      const startHour = Math.floor(Math.min(...hiddenEvents.map(e => minutesOf(e.startDate))) / 60);
      const endHour = Math.ceil(Math.max(...hiddenEvents.map(e => minutesOf(e.endDate))) / 60);
      overflows.push({ events: hiddenEvents, band: { startHour, endHour } });
    }
  }

  return { cards, overflows };
}

export function useDayLayout(events: readonly Event[] | undefined): DayLayout {
  return useMemo(() => layoutDay(events ?? []), [events]);
}
  • Step 4: Run test to verify it passes
Run: cd ui && pnpm vitest run src/hooks/use-day-layout.test.ts Expected: PASS.
  • Step 5: Commit
git add ui/src/hooks/use-day-layout.ts ui/src/hooks/use-day-layout.test.ts
git commit -m "feat(calendar): layoutDay — pure overlap clustering with overflow groups"

Task 13: Day v2 — skeleton with hour rail + zoom

Files:
  • Create: ui/src/components/schedule/_components/view/day/day-view-v2.tsx
  • Step 1: Create the skeleton file
Create ui/src/components/schedule/_components/view/day/day-view-v2.tsx:
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import clsx from 'clsx';
import { useScheduler } from '@/providers/schedular-provider';
import { useRolePersistedState } from '@/hooks/use-role-persisted-state';
import { useDayIndex, EMPTY_EVENTS } from '@/hooks/use-day-index';
import { useDayLayout } from '@/hooks/use-day-layout';
import { operatingMinutesFor, bookedMinutesFor } from '@/hooks/use-operating-minutes';
import { localDateStr } from '@/lib/date-range';
import { useClinicTimeFormat } from '@/lib/useClinicTimeFormat';
import { formatHourLabel } from '@/lib/datetime';
import { EventCard } from '../../v2/EventCard';
import { OverflowPill } from '../../v2/OverflowPill';
import { NowLine } from '../../v2/NowLine';
import { UtilizationBar } from '../../v2/Utilization';
import type { Event } from '@/types';
import { Button } from '@/components/ui/button';
import { ZoomIn, ZoomOut } from 'lucide-react';

interface Props {
  currentDate?: Date;
  onDateChange?: (date: Date) => void;
  startHour?: number;
  endHour?: number;
  hideHeader?: boolean;
}

const ZOOM_LEVELS = [60, 90, 120, 160] as const;
type ZoomPx = typeof ZOOM_LEVELS[number];
const HOUR_GUTTER_PX = 96;

export default function DayViewV2({ currentDate: propDate, startHour = 9, endHour = 18, hideHeader }: Props) {
  const { getters: _g, operatingHours, filterDoctorId, filterStatus } = useScheduler();
  const timeFormat = useClinicTimeFormat();
  const [internalDate] = useState<Date>(new Date());
  const currentDate = propDate || internalDate;
  const [pxPerHour, setPxPerHour] = useRolePersistedState<ZoomPx>('calendar.day.zoom', 120 as ZoomPx);

  // Pull events from context — schedular-provider exposes state via `state.events`
  // through the getters object; we don't have a direct hook so we derive via the
  // existing get-all-for-day API.
  const { state } = useScheduler() as any; // legacy provider exposes state on context
  const allEvents: Event[] = state?.events ?? [];
  const { byDate } = useDayIndex(allEvents, filterDoctorId, filterStatus);

  const dateKey = useMemo(() => localDateStr(currentDate), [currentDate]);
  const events = byDate.get(dateKey) ?? EMPTY_EVENTS;

  const layout = useDayLayout(events);

  const hours = useMemo(() => {
    const out: string[] = [];
    for (let h = startHour; h < endHour; h++) out.push(formatHourLabel(h, timeFormat));
    return out;
  }, [startHour, endHour, timeFormat]);

  const totalHours = endHour - startHour;
  const totalPx = totalHours * pxPerHour;

  const operatingMins = useMemo(() => operatingMinutesFor(currentDate, operatingHours), [currentDate, operatingHours]);
  const bookedMins = useMemo(() => bookedMinutesFor(events), [events]);

  const handleEventClick = useCallback((event: Event) => {
    window.dispatchEvent(new CustomEvent('edit-appointment', { detail: { id: event.id, ...event } }));
  }, []);

  const cycleZoom = useCallback((direction: 1 | -1) => {
    const idx = ZOOM_LEVELS.indexOf(pxPerHour as ZoomPx);
    const next = ZOOM_LEVELS[Math.max(0, Math.min(ZOOM_LEVELS.length - 1, idx + direction))];
    setPxPerHour(next);
  }, [pxPerHour, setPxPerHour]);

  return (
    <div className="flex h-full flex-col overflow-hidden">
      {!hideHeader && (
        <div className="flex items-center justify-between border-b px-4 py-2">
          <div className="flex items-center gap-3">
            <h2 className="text-xl font-semibold">{currentDate.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</h2>
            <span className="text-sm text-muted-foreground">{events.length} appointments</span>
          </div>
          <div className="flex items-center gap-1">
            <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => cycleZoom(-1)} aria-label="Zoom out"><ZoomOut className="h-3.5 w-3.5" /></Button>
            <span className="px-1 text-xs tabular-nums text-muted-foreground">{pxPerHour}px/h</span>
            <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => cycleZoom(1)} aria-label="Zoom in"><ZoomIn className="h-3.5 w-3.5" /></Button>
          </div>
        </div>
      )}
      <div className="px-4 pt-2">
        <UtilizationBar bookedMinutes={bookedMins} operatingMinutes={operatingMins} count={events.length} />
      </div>
      <div className="flex-1 overflow-y-auto">
        <div className="relative" style={{ height: `${totalPx}px` }}>
          {/* Hour rail */}
          <div className="absolute inset-y-0 left-0 border-r" style={{ width: `${HOUR_GUTTER_PX}px` }}>
            {hours.map((label, i) => (
              <div key={i} className="border-b text-right" style={{ height: `${pxPerHour}px` }}>
                <span className="pr-2 text-xs tabular-nums text-muted-foreground">{label}</span>
              </div>
            ))}
          </div>
          {/* Day column */}
          <div className="absolute inset-y-0 right-0" style={{ left: `${HOUR_GUTTER_PX}px` }}>
            {/* Grid lines */}
            {Array.from({ length: totalHours }).map((_, i) => (
              <div key={i} className="border-b" style={{ height: `${pxPerHour}px` }} />
            ))}
            <NowLine startHour={startHour} endHour={endHour} pxPerHour={pxPerHour} />

            {/* Cards positioned absolutely */}
            {layout.cards.map(({ event, column, totalColumns, startMinutes, endMinutes }) => {
              const top = ((startMinutes / 60) - startHour) * pxPerHour;
              const height = Math.max(24, ((endMinutes - startMinutes) / 60) * pxPerHour - 2);
              const width = `calc(${100 / totalColumns}% - 4px)`;
              const left = `calc(${(column * 100) / totalColumns}% + 2px)`;
              return (
                <div key={event.id} className="absolute" style={{ top, height, width, left }}>
                  <EventCard
                    event={event}
                    width={totalColumns <= 1 ? 'wide' : totalColumns === 2 ? 'normal' : 'narrow'}
                    onClick={handleEventClick}
                  />
                </div>
              );
            })}

            {/* Overflow pills, anchored to band start */}
            {layout.overflows.map((og, i) => {
              const top = (og.band.startHour - startHour) * pxPerHour + 4;
              return (
                <div key={i} className="absolute right-2" style={{ top }}>
                  <OverflowPill events={og.events} band={og.band} onSelect={handleEventClick} />
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </div>
  );
}
  • Step 2: Compile-check
Run: cd ui && pnpm tsc --noEmit Expected: no errors. If useScheduler() as any triggers a lint warning, that’s intentional — the legacy provider doesn’t export state on its type yet. (Task 17 narrows this.)
  • Step 3: Smoke-render in the existing app
Edit ui/src/components/schedule/_components/view/schedular-view-filteration.tsx: temporarily swap import DailyView from './day/daily-view'; to import DailyView from './day/day-view-v2'; and reload the dev server. Switch to Day view in the app, confirm appointments render. Revert the import before committing this task — the wiring is done in Task 17.
  • Step 4: Commit
git add ui/src/components/schedule/_components/view/day/day-view-v2.tsx
git commit -m "feat(calendar): day-view-v2 — hour rail + zoom + overlap layout"

Task 14: Day v2 — empty state + dense-day compact fallback

Files:
  • Modify: ui/src/components/schedule/_components/view/day/day-view-v2.tsx
  • Step 1: Add empty-state + dense-day rendering
In day-view-v2.tsx, find the <div className="absolute inset-y-0 right-0"> containing the day column. Right after the <NowLine ...> line, add a dense-mode short-circuit:
{events.length === 0 && (
  <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-center text-muted-foreground">
    <p className="text-sm">No appointments today</p>
    <div className="flex gap-2">
      <Button size="sm" onClick={() => window.dispatchEvent(new CustomEvent('create-appointment', { detail: { date: dateKey, time: '09:00', duration: 60 } }))}>
        Add appointment
      </Button>
      <Button size="sm" variant="outline" onClick={() => {
        // Find next non-empty day via byDate
        const todayKey = dateKey;
        const sorted = Array.from(byDate.keys()).filter(k => k > todayKey).sort();
        if (sorted[0] && propDate) (props as any).onDateChange?.(new Date(`${sorted[0]}T09:00:00`));
      }}>
        Jump to next booking
      </Button>
    </div>
  </div>
)}
The Jump to next booking button calls (props as any).onDateChange?.(...) — change the signature of the component to destructure onDateChange (already in Props) and call it directly:
export default function DayViewV2({ currentDate: propDate, onDateChange, startHour = 9, endHour = 18, hideHeader }: Props) {
Then in the empty-state button:
onClick={() => {
  const todayKey = dateKey;
  const futureKeys = Array.from(byDate.keys()).filter(k => k > todayKey).sort();
  if (futureKeys[0]) {
    onDateChange?.(new Date(`${futureKeys[0]}T09:00:00`));
  }
}}
  • Step 2: Add dense-day compact variant
Just inside the day column, compute density and switch card variant:
const isDense = events.length > 60;
Change the card rendering inside layout.cards.map(...) so it passes variant={isDense ? 'compact' : 'normal'}:
<EventCard
  event={event}
  variant={isDense ? 'compact' : 'normal'}
  width={totalColumns <= 1 ? 'wide' : totalColumns === 2 ? 'normal' : 'narrow'}
  onClick={handleEventClick}
/>
  • Step 3: Compile-check
Run: cd ui && pnpm tsc --noEmit Expected: no errors.
  • Step 4: Commit
git add ui/src/components/schedule/_components/view/day/day-view-v2.tsx
git commit -m "feat(calendar): day-view-v2 — empty state + compact-card dense fallback"

Task 15: Week v2

Files:
  • Create: ui/src/components/schedule/_components/view/week/week-view-v2.tsx
  • Step 1: Create the file
Create ui/src/components/schedule/_components/view/week/week-view-v2.tsx:
import React, { useMemo, useCallback, useState } from 'react';
import clsx from 'clsx';
import { useScheduler } from '@/providers/schedular-provider';
import { useDeviceType } from '@/components/ui/use-mobile';
import { useRolePersistedState } from '@/hooks/use-role-persisted-state';
import { useDayIndex, EMPTY_EVENTS } from '@/hooks/use-day-index';
import { layoutDay } from '@/hooks/use-day-layout';
import { operatingMinutesFor, bookedMinutesFor } from '@/hooks/use-operating-minutes';
import { localDateStr } from '@/lib/date-range';
import { useClinicTimeFormat } from '@/lib/useClinicTimeFormat';
import { formatHourLabel } from '@/lib/datetime';
import { EventCard } from '../../v2/EventCard';
import { OverflowPill } from '../../v2/OverflowPill';
import { NowLine } from '../../v2/NowLine';
import { UtilizationBar } from '../../v2/Utilization';
import { MoonStar } from 'lucide-react';
import type { Event } from '@/types';

const DAY_KEYS = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'] as const;

interface Props {
  currentDate?: Date;
  onDateChange?: (date: Date) => void;
  startHour?: number;
  endHour?: number;
  hideHeader?: boolean;
}

const HOUR_GUTTER_PX = 80;
const ZOOM_LEVELS = [60, 90, 120] as const;
type ZoomPx = typeof ZOOM_LEVELS[number];

function getVisibleDays(currentDate: Date, isMobile: boolean, isTablet: boolean): Date[] {
  const anchor = new Date(currentDate);
  // Monday-start week
  const dow = anchor.getDay();
  const offsetToMonday = (dow === 0 ? -6 : 1 - dow);
  const monday = new Date(anchor);
  monday.setDate(anchor.getDate() + offsetToMonday);
  monday.setHours(0, 0, 0, 0);
  const span = isMobile ? 1 : isTablet ? 3 : 7;
  const days: Date[] = [];
  // When mobile/tablet we center on currentDate; otherwise show full week.
  const startOffset = isMobile || isTablet ? Math.max(0, anchor.getDay() - (isMobile ? 0 : 1)) : 0;
  for (let i = 0; i < span; i++) {
    const d = new Date(monday);
    d.setDate(monday.getDate() + startOffset + i);
    days.push(d);
  }
  return days;
}

export default function WeekViewV2({ currentDate: propDate, onDateChange, startHour = 9, endHour = 18, hideHeader }: Props) {
  const { state, operatingHours, filterDoctorId, filterStatus } = useScheduler() as any;
  const timeFormat = useClinicTimeFormat();
  const { isMobile, isTablet } = useDeviceType();
  const currentDate = propDate || new Date();
  const [pxPerHour] = useRolePersistedState<ZoomPx>('calendar.week.zoom', 90 as ZoomPx);

  const allEvents: Event[] = state?.events ?? [];
  const { byDate } = useDayIndex(allEvents, filterDoctorId, filterStatus);

  const visibleDays = useMemo(() => getVisibleDays(currentDate, isMobile, isTablet), [currentDate, isMobile, isTablet]);

  const hours = useMemo(() => {
    const out: string[] = [];
    for (let h = startHour; h < endHour; h++) out.push(formatHourLabel(h, timeFormat));
    return out;
  }, [startHour, endHour, timeFormat]);

  const totalHours = endHour - startHour;
  const totalPx = totalHours * pxPerHour;

  const handleEventClick = useCallback((event: Event) => {
    window.dispatchEvent(new CustomEvent('edit-appointment', { detail: { id: event.id, ...event } }));
  }, []);

  const goToDay = useCallback((day: Date) => {
    onDateChange?.(day);
    window.dispatchEvent(new CustomEvent('calendar-view-change', { detail: { view: 'day' } }));
  }, [onDateChange]);

  return (
    <div className="flex h-full flex-col overflow-hidden">
      {!hideHeader && (
        <div className="border-b px-4 py-2">
          <h2 className="text-xl font-semibold">Week of {visibleDays[0]?.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</h2>
        </div>
      )}
      <div className="flex-1 overflow-y-auto">
        <div className="grid" style={{ gridTemplateColumns: `${HOUR_GUTTER_PX}px repeat(${visibleDays.length}, minmax(0, 1fr))` }}>
          {/* Day-header row */}
          <div className="sticky top-0 z-20 border-b bg-background" />
          {visibleDays.map((day) => {
            const key = localDateStr(day);
            const dayEvents = byDate.get(key) ?? EMPTY_EVENTS;
            const dayKey = DAY_KEYS[day.getDay()];
            const isClosed = operatingHours?.[dayKey]?.enabled === false;
            const opMins = operatingMinutesFor(day, operatingHours);
            const bookMins = bookedMinutesFor(dayEvents);
            const isToday = localDateStr(new Date()) === key;
            return (
              <button
                key={key}
                type="button"
                onClick={() => goToDay(day)}
                className={clsx(
                  'sticky top-0 z-20 flex flex-col gap-1 border-b border-r bg-background px-2 py-2 text-left transition-colors hover:bg-muted/50',
                  isToday && 'bg-primary/5',
                )}
              >
                <div className="flex items-baseline justify-between">
                  <span className="text-[11px] uppercase tracking-wide text-muted-foreground">{day.toLocaleDateString(undefined, { weekday: 'short' })}</span>
                  <span className="text-[10px] text-muted-foreground">{dayEvents.length}</span>
                </div>
                <div className={clsx('text-2xl font-semibold', isToday && 'text-primary')}>{day.getDate()}</div>
                <UtilizationBar bookedMinutes={bookMins} operatingMinutes={opMins} count={dayEvents.length} />
              </button>
            );
          })}

          {/* Hour rail */}
          <div className="border-r">
            {hours.map((label, i) => (
              <div key={i} className="border-b text-right" style={{ height: `${pxPerHour}px` }}>
                <span className="pr-2 text-[11px] tabular-nums text-muted-foreground">{label}</span>
              </div>
            ))}
          </div>

          {/* Day columns */}
          {visibleDays.map((day) => {
            const key = localDateStr(day);
            const dayEvents = byDate.get(key) ?? EMPTY_EVENTS;
            const layout = layoutDay(dayEvents);
            const dayKey = DAY_KEYS[day.getDay()];
            const isClosed = operatingHours?.[dayKey]?.enabled === false;
            const isToday = localDateStr(new Date()) === key;
            return (
              <div key={key} className="relative border-r" style={{ height: `${totalPx}px` }}>
                {Array.from({ length: totalHours }).map((_, i) => (
                  <div key={i} className="border-b" style={{ height: `${pxPerHour}px` }} />
                ))}
                {isClosed && (
                  <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1 bg-muted/40">
                    <MoonStar className="h-5 w-5 text-muted-foreground/50" />
                    <span className="text-[10px] uppercase tracking-wide text-muted-foreground/60">Closed</span>
                  </div>
                )}
                {isToday && <NowLine startHour={startHour} endHour={endHour} pxPerHour={pxPerHour} />}
                {layout.cards.map(({ event, column, totalColumns, startMinutes, endMinutes }) => {
                  const top = ((startMinutes / 60) - startHour) * pxPerHour;
                  const height = Math.max(20, ((endMinutes - startMinutes) / 60) * pxPerHour - 2);
                  const width = `calc(${100 / totalColumns}% - 4px)`;
                  const left = `calc(${(column * 100) / totalColumns}% + 2px)`;
                  return (
                    <div key={event.id} className="absolute" style={{ top, height, width, left }}>
                      <EventCard
                        event={event}
                        variant={dayEvents.length > 24 ? 'compact' : 'normal'}
                        width={totalColumns <= 1 ? 'normal' : 'narrow'}
                        onClick={handleEventClick}
                      />
                    </div>
                  );
                })}
                {layout.overflows.map((og, i) => {
                  const top = (og.band.startHour - startHour) * pxPerHour + 4;
                  return (
                    <div key={i} className="absolute right-1" style={{ top }}>
                      <OverflowPill events={og.events} band={og.band} onSelect={handleEventClick} />
                    </div>
                  );
                })}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}
  • Step 2: Compile-check
Run: cd ui && pnpm tsc --noEmit Expected: no errors.
  • Step 3: Smoke-render (temporarily swap import in schedular-view-filteration.tsx, verify, revert before committing).
  • Step 4: Commit
git add ui/src/components/schedule/_components/view/week/week-view-v2.tsx
git commit -m "feat(calendar): week-view-v2 — utilization headers, indexed lookups, overflow pills"

Task 16: Month v2

Files:
  • Create: ui/src/components/schedule/_components/view/month/month-view-v2.tsx
  • Step 1: Create the file
Create ui/src/components/schedule/_components/view/month/month-view-v2.tsx:
import React, { useMemo, useCallback } from 'react';
import clsx from 'clsx';
import { useScheduler } from '@/providers/schedular-provider';
import { useDeviceType } from '@/components/ui/use-mobile';
import { useDayIndex, EMPTY_EVENTS } from '@/hooks/use-day-index';
import { operatingMinutesFor, bookedMinutesFor } from '@/hooks/use-operating-minutes';
import { localDateStr } from '@/lib/date-range';
import { UtilizationRing } from '../../v2/Utilization';
import { HourHeatStrip } from '../../v2/HourHeatStrip';
import { statusToDot } from '../../v2/status-style';
import type { Event } from '@/types';

interface Props {
  currentDate?: Date;
  onDateChange?: (date: Date) => void;
  onViewChange?: (view: string) => void;
  hideHeader?: boolean;
}

const DAY_KEYS = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'] as const;

function getMonthGrid(currentDate: Date, weekStartsOn: 'monday' | 'sunday'): Array<{ date: Date; inMonth: boolean }> {
  const year = currentDate.getFullYear();
  const month = currentDate.getMonth();
  const firstOfMonth = new Date(year, month, 1);
  const startDow = firstOfMonth.getDay();
  const startOffset = (startDow - (weekStartsOn === 'monday' ? 1 : 0) + 7) % 7;
  const daysInMonth = new Date(year, month + 1, 0).getDate();
  const totalCells = Math.ceil((startOffset + daysInMonth) / 7) * 7;
  const grid: Array<{ date: Date; inMonth: boolean }> = [];
  for (let i = 0; i < totalCells; i++) {
    const dayOffset = i - startOffset + 1;
    const d = new Date(year, month, dayOffset);
    grid.push({ date: d, inMonth: d.getMonth() === month });
  }
  return grid;
}

export default function MonthViewV2({ currentDate: propDate, onDateChange, onViewChange, hideHeader }: Props) {
  const { state, operatingHours, filterDoctorId, filterStatus, weekStartsOn } = useScheduler() as any;
  const { isMobile } = useDeviceType();
  const currentDate = propDate || new Date();

  const allEvents: Event[] = state?.events ?? [];
  const { byDate, byDateByHour } = useDayIndex(allEvents, filterDoctorId, filterStatus);
  const grid = useMemo(() => getMonthGrid(currentDate, weekStartsOn ?? 'monday'), [currentDate, weekStartsOn]);

  const daysOfWeek = weekStartsOn === 'monday'
    ? ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
    : ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];

  const handleCellClick = useCallback((d: Date) => {
    onDateChange?.(d);
    onViewChange?.('day');
  }, [onDateChange, onViewChange]);

  const todayKey = localDateStr(new Date());

  return (
    <div className="flex h-full flex-col overflow-hidden">
      {!hideHeader && (
        <div className="border-b px-4 py-2">
          <h2 className="text-xl font-semibold">{currentDate.toLocaleString(undefined, { month: 'long', year: 'numeric' })}</h2>
        </div>
      )}
      <div className="grid grid-cols-7 gap-px border-b bg-muted/40 text-center text-xs font-medium text-muted-foreground">
        {daysOfWeek.map(d => <div key={d} className="bg-background py-2">{d}</div>)}
      </div>
      <div className="grid flex-1 grid-cols-7 gap-px overflow-y-auto bg-muted/30">
        {grid.map(({ date, inMonth }, i) => {
          const key = localDateStr(date);
          const dayEvents = byDate.get(key) ?? EMPTY_EVENTS;
          const byHour = byDateByHour.get(key);
          const opMins = operatingMinutesFor(date, operatingHours);
          const bookMins = bookedMinutesFor(dayEvents);
          const isToday = key === todayKey;

          // Status breakdown: top 4 statuses by count
          const statusCounts = new Map<string, number>();
          for (const ev of dayEvents) {
            const s = (ev.status as string) || 'scheduled';
            statusCounts.set(s, (statusCounts.get(s) || 0) + 1);
          }
          const topStatuses = Array.from(statusCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 4);

          return (
            <button
              key={`${key}-${i}`}
              type="button"
              onClick={() => handleCellClick(date)}
              className={clsx(
                'group relative flex flex-col bg-background p-2 text-left transition-colors hover:bg-muted/40',
                isMobile ? 'min-h-[80px]' : 'min-h-[140px]',
                !inMonth && 'opacity-40',
              )}
            >
              <div className="mb-1 flex items-start justify-between">
                <div className={clsx(
                  'text-base font-semibold tabular-nums',
                  isToday && 'inline-flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground',
                )}>
                  {date.getDate()}
                </div>
                {dayEvents.length > 0 && (
                  <UtilizationRing
                    bookedMinutes={bookMins}
                    operatingMinutes={opMins}
                    count={dayEvents.length}
                    showCount
                    size={24}
                  />
                )}
              </div>
              {!isMobile && dayEvents.length > 0 && (
                <HourHeatStrip byHour={byHour} windowStart={9} windowEnd={19} className="mt-auto" />
              )}
              {!isMobile && topStatuses.length > 0 && (
                <div className="mt-1 flex items-center gap-1.5 text-[10px] text-muted-foreground">
                  {topStatuses.map(([s, n]) => (
                    <span key={s} className="inline-flex items-center gap-0.5">
                      <span className={clsx('h-1.5 w-1.5 rounded-full', statusToDot(s))} />
                      <span className="tabular-nums">{n}</span>
                    </span>
                  ))}
                </div>
              )}
            </button>
          );
        })}
      </div>
    </div>
  );
}
  • Step 2: Compile-check
Run: cd ui && pnpm tsc --noEmit Expected: no errors.
  • Step 3: Smoke-render (temporarily swap import, verify, revert).
  • Step 4: Commit
git add ui/src/components/schedule/_components/view/month/month-view-v2.tsx
git commit -m "feat(calendar): month-view-v2 — heat strip + utilization ring + status dots"

Task 17: Wire v2 via schedular-view-filteration.tsx with legacy escape hatch

Files:
  • Modify: ui/src/components/schedule/_components/view/schedular-view-filteration.tsx
  • Step 1: Add the v2 imports
Open ui/src/components/schedule/_components/view/schedular-view-filteration.tsx. After the existing imports for DailyView, MonthView, WeeklyView, add:
import DayViewV2 from './day/day-view-v2';
import WeekViewV2 from './week/week-view-v2';
import MonthViewV2 from './month/month-view-v2';
  • Step 2: Detect the legacy escape-hatch query flag at mount
Inside the SchedulerViewFilteration component body (near where state is declared), add:
const useLegacy = useMemo(() => {
  if (typeof window === 'undefined') return false;
  return new URLSearchParams(window.location.search).get('calendar') === 'legacy';
}, []);
Add useMemo to the React imports if not already imported.
  • Step 3: Swap each view’s render
Replace the existing <DailyView ...> JSX (inside the 'day' TabsContent, both the timeline branch — not the doctor swimlane branch) with:
{useLegacy ? (
  <DailyView
    stopDayEventSummary={stopDayEventSummary}
    classNames={classNames?.buttons}
    prevButton={CustomComponents?.customButtons?.CustomPrevButton}
    nextButton={CustomComponents?.customButtons?.CustomNextButton}
    CustomEventComponent={CustomComponents?.CustomEventComponent}
    CustomEventModal={CustomComponents?.CustomEventModal}
    startHour={startHour}
    endHour={endHour}
    currentDate={currentDate}
    onDateChange={onDateChange}
    hideHeader={true}
  />
) : (
  <DayViewV2
    startHour={startHour}
    endHour={endHour}
    currentDate={currentDate}
    onDateChange={onDateChange}
    hideHeader={true}
  />
)}
Replace <WeeklyView ...> JSX with:
{useLegacy ? (
  <WeeklyView
    classNames={classNames?.buttons}
    prevButton={CustomComponents?.customButtons?.CustomPrevButton}
    nextButton={CustomComponents?.customButtons?.CustomNextButton}
    CustomEventComponent={CustomComponents?.CustomEventComponent}
    CustomEventModal={CustomComponents?.CustomEventModal}
    startHour={startHour}
    endHour={endHour}
    currentDate={currentDate}
    onDateChange={onDateChange}
    hideHeader={true}
  />
) : (
  <WeekViewV2
    startHour={startHour}
    endHour={endHour}
    currentDate={currentDate}
    onDateChange={onDateChange}
    hideHeader={true}
  />
)}
Replace <MonthView ...> JSX with:
{useLegacy ? (
  <MonthView
    classNames={classNames?.buttons}
    prevButton={CustomComponents?.customButtons?.CustomPrevButton}
    nextButton={CustomComponents?.customButtons?.CustomNextButton}
    CustomEventComponent={CustomComponents?.CustomEventComponent}
    CustomEventModal={CustomComponents?.CustomEventModal}
    currentDate={currentDate}
    onDateChange={onDateChange}
    onViewChange={setActiveView}
    hideHeader={true}
  />
) : (
  <MonthViewV2
    currentDate={currentDate}
    onDateChange={onDateChange}
    onViewChange={setActiveView}
    hideHeader={true}
  />
)}
  • Step 4: Compile-check
Run: cd ui && pnpm tsc --noEmit Expected: no errors.
  • Step 5: Manual QA
Start the dev server (cd ui && pnpm dev) and verify against the canonical test tenant ssh & Associates (clinic id b6d3a3f3-…):
  1. Day view loads, appointments render, hour zoom buttons work.
  2. Week view loads, day headers show utilization bars + counts.
  3. Month view loads, heat strips visible on days with appointments.
  4. ?calendar=legacy reverts to the old views.
  5. Navigate to a week that crosses a month boundary — confirm both views render appointments on both halves of the week.
  • Step 6: Commit
git add ui/src/components/schedule/_components/view/schedular-view-filteration.tsx
git commit -m "feat(calendar): wire v2 views with ?calendar=legacy escape hatch"

Task 18: Narrow the state access on useScheduler() (type safety)

Files:
  • Modify: ui/src/providers/schedular-provider.tsx
  • Modify: ui/src/types/index.ts (if SchedulerContextType lives there)
  • Step 1: Check where SchedulerContextType is defined
Run: grep -n "SchedulerContextType" ui/src/types/index.ts ui/src/providers/schedular-provider.tsx | head -10 Expected: at least one definition in ui/src/types/index.ts.
  • Step 2: Expose state on the context type
In whichever file declares SchedulerContextType, add a field:
state: { events: Event[] };
Then in ui/src/providers/schedular-provider.tsx, the value provided to SchedulerContext.Provider already includes state indirectly — find the JSX <SchedulerContext.Provider value={...}> and ensure the object contains state. If it’s not there, add it:
<SchedulerContext.Provider value={{
  state, // ← add if missing
  dispatch, getters, handlers, weekStartsOn, operatingHours, doctorOffToday, doctorEffectiveHours,
  filterDoctorId, setFilterDoctorId, filterStatus, setFilterStatus,
}}>
  • Step 3: Drop the as any casts in v2 views
In day-view-v2.tsx, week-view-v2.tsx, and month-view-v2.tsx, change:
const { state, operatingHours, filterDoctorId, filterStatus } = useScheduler() as any;
to:
const { state, operatingHours, filterDoctorId, filterStatus } = useScheduler();
(Repeat the same removal for the other view files.)
  • Step 4: Compile-check
Run: cd ui && pnpm tsc --noEmit Expected: no errors.
  • Step 5: Run all v2 tests
Run: cd ui && pnpm vitest run src/components/schedule/_components/v2 src/hooks/use-day-index.test.tsx src/hooks/use-day-layout.test.ts src/hooks/use-operating-minutes.test.ts src/lib/date-range.test.ts src/lib/getAppointments.test.ts Expected: all green.
  • Step 6: Commit
git add ui/src/providers/schedular-provider.tsx ui/src/types/index.ts ui/src/components/schedule/_components/view/day/day-view-v2.tsx ui/src/components/schedule/_components/view/week/week-view-v2.tsx ui/src/components/schedule/_components/view/month/month-view-v2.tsx
git commit -m "refactor(calendar): expose state on SchedulerContextType, drop v2 any casts"

Task 19: Release notes + APP_VERSION bump

Files:
  • Modify: ui/src/components/pages/sign-in.tsx (APP_VERSION)
  • Create: docs/releases/v1.11.0-calendar-v2.md
  • Step 1: Bump APP_VERSION
Find the current APP_VERSION line in ui/src/components/pages/sign-in.tsx (it’ll be near the bottom of the sign-in form footer) and bump from v1.10.0 to v1.11.0.
  • Step 2: Write the release note
Create docs/releases/v1.11.0-calendar-v2.md:
# v1.11.0 — Calendar v2

## What's new
- **Spacious, redesigned Day, Week, and Month views.** Generous hour rails, a 4px status ribbon on every appointment card, hover elevation, tabular-num times, and a per-user zoom control on Day.
- **Utilization at a glance.** Week day headers show a 4px booked-vs-operating bar. Month cells show a small utilization ring with the appointment count inside.
- **Hourly heat strip on Month cells.** See which hours each day is busy without opening it.
- **Smart overflow.** When 4+ appointments overlap in the same band, the third and onward collapse into a "+N at HH:00" popover instead of squeezing into unreadable columns.

## Why
Clinics with 50+ appointments per day were seeing empty Week and Month views — the API's 100-row cap quietly truncated those windows. Calendar v2 fixes that and adds the visual density tools that make a busy calendar legible.

## Fixes
- `/api/v1/protected/appointments` cap raised from 100 → 2000; default 50 → 500. Client fetches with `limit=2000` and recursively splits the range if it ever fills.
- Week and Month no longer drop appointments when the visible window crosses a month boundary.

## Rollback
The previous views remain on every page; append `?calendar=legacy` to the URL to use them. The escape hatch is removed in the next release.
  • Step 3: Compile-check
Run: cd ui && pnpm tsc --noEmit Expected: no errors.
  • Step 4: Commit
git add ui/src/components/pages/sign-in.tsx docs/releases/v1.11.0-calendar-v2.md
git commit -m "docs(releases): v1.11.0 — calendar v2 + appointments-pagination fix"

Task 20: Final smoke matrix + deploy

Files:
  • No code changes — verification and deploy.
  • Step 1: Run the full test suite
Run: cd ui && pnpm vitest run Expected: all green (no v2 regressions). Run: cd server && pnpm vitest run Expected: all green.
  • Step 2: Manual smoke matrix on the test tenant
Sign in as a clinic admin at ssh & Associates and verify:
  1. Day with 0 events — empty state renders with the two CTAs.
  2. Day with a handful of events — cards render with correct status ribbons and times.
  3. Day with 30+ events — overflow pills appear on hours with 4+ overlapping events.
  4. Day with 100+ events — compact card variant kicks in; performance is smooth.
  5. Week view of the current week — utilization bars on each day header.
  6. Week view straddling a month boundary — both halves show their events.
  7. Month view of the current month — heat strips visible, today’s cell ringed.
  8. Clicking a month cell jumps to Day for that date.
  9. ?calendar=legacy — old views render.
  • Step 3: Deploy via the canonical workflow
Run: invoke the odontox-commit-deploy skill. It runs the safety checks, builds the bundle, deploys to Cloudflare Pages, and force-promotes the CF canonical.
  • Step 4: Post-deploy verification
Open https://go.odontox.io/appointments in an incognito window, sign in, and re-run smoke matrix items 1–8 against the live deploy.
  • Step 5: Final commit (if any cleanup arose during smoke)
Only commit if smoke uncovered a fix:
git add -A
git commit -m "fix(calendar-v2): <specific issue from smoke>"

Self-review

Spec coverage:
  • Data layer fix (cap + client) — Tasks 1, 3.
  • useDayIndex — Task 4.
  • Shared primitives (EventCard, OverflowPill, UtilizationBar, UtilizationRing, HourHeatStrip, NowLine) — Tasks 5–10.
  • Helpers (useOperatingMinutes, useDayLayout) — Tasks 11–12.
  • Day v2 (zoom, ribbon, dense fallback, empty state, overflow) — Tasks 13–14. By-doctor swim-lane mode is deferred — added as an explicit follow-up below since the existing DoctorDayView already covers the case and a v2 swim-lane is a larger isolated piece of work.
  • Week v2 (utilization headers, day-header click-through, closed-day wash, overflow) — Task 15.
  • Month v2 (heat strip, utilization ring, status dots, hover-to-peek omitted — see follow-up) — Task 16.
  • View selector with legacy flag — Task 17.
  • Type cleanup — Task 18.
  • Release notes — Task 19.
  • Smoke matrix + deploy — Task 20.
Deferred to follow-up plan (out of scope for this PR):
  • Day v2 by-doctor swim-lane mode (existing DoctorDayView remains available via the day-mode toggle for now).
  • Month v2 day-peek hover popover (cells already drill into Day on click).
  • Mobile parity polish on v2 views (mobile path still routes through current mobile-specific code paths).
These are documented in the spec’s Risks section and can be picked up after v1.11.0 ships clean. Placeholder scan: none — every step has full code, exact paths, and explicit run commands. Type consistency: LaidOutCard.totalColumns, OverflowGroup.band.{startHour,endHour}, and DayIndex.{byDate,byDateByHour} names match across use-day-layout.ts, use-day-index.ts, and the three view files. EventCardProps.{event, variant, width, onClick} names match between definition (Task 6) and call sites (Tasks 13, 15, 16).

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-21-calendar-v2.md. Two execution options:
  1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration.
  2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?