diff --git a/docs/superpowers/plans/2026-06-14-vynte-scheduler-replacement.md b/docs/superpowers/plans/2026-06-14-vynte-scheduler-replacement.md new file mode 100644 index 0000000000..a34ecc9f23 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-vynte-scheduler-replacement.md @@ -0,0 +1,966 @@ +# Vynte Scheduler Replacement 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:** Build a small internal scheduler app at `apps/scheduler` that replaces Cal.diy's current interface for Authentik-only Vynte team scheduling. + +**Architecture:** Add a separate Next.js App Router app that shares Cal's database, auth session, and reusable scheduling primitives, but owns a small scheduler-specific UI and API surface. Keep the first version table-free: use existing `User`, `Schedule`, `Availability`, `Credential`, `SelectedCalendar`, and `Booking` data. Implement the product as a local runnable MVP with narrow service boundaries for future deeper Cal booking integration. + +**Tech Stack:** Next.js App Router, React, TypeScript, CSS modules/global CSS, Prisma via `@calcom/prisma`, existing Cal auth helper, Vitest, Yarn 4 workspaces. + +--- + +## File Structure + +- Create `apps/scheduler/package.json`: workspace package scripts and dependencies. +- Create `apps/scheduler/next.config.ts`: minimal Next config for a monorepo app. +- Create `apps/scheduler/tsconfig.json`: extends `@calcom/tsconfig/nextjs.json`. +- Create `apps/scheduler/next-env.d.ts`: Next type marker. +- Create `apps/scheduler/app/layout.tsx`: root layout. +- Create `apps/scheduler/app/page.tsx`: redirect to `/calendar`. +- Create `apps/scheduler/app/globals.css`: approved visual system. +- Create `apps/scheduler/app/calendar/page.tsx`: calendar-first scheduling screen. +- Create `apps/scheduler/app/availability/page.tsx`: weekly schedule plus overrides. +- Create `apps/scheduler/app/connections/page.tsx`: calendar and conferencing provider screen. +- Create `apps/scheduler/app/api/scheduler/team/route.ts`: team members API. +- Create `apps/scheduler/app/api/scheduler/availability/route.ts`: mutual availability API. +- Create `apps/scheduler/app/api/scheduler/schedule/route.ts`: schedule read/write API. +- Create `apps/scheduler/app/api/scheduler/connections/route.ts`: provider metadata API. +- Create `apps/scheduler/app/api/scheduler/meetings/route.ts`: meetings list/create API. +- Create `apps/scheduler/components/AppShell.tsx`: side navigation and page frame. +- Create `apps/scheduler/components/CalendarWorkspace.tsx`: interactive calendar and composer. +- Create `apps/scheduler/components/AvailabilityEditor.tsx`: weekly schedule editor. +- Create `apps/scheduler/components/ConnectionsView.tsx`: provider list. +- Create `apps/scheduler/lib/scheduler/types.ts`: shared domain types. +- Create `apps/scheduler/lib/scheduler/time.ts`: week and time helpers. +- Create `apps/scheduler/lib/scheduler/privacy.ts`: free/busy privacy filtering. +- Create `apps/scheduler/lib/scheduler/availability.ts`: mutual availability calculation. +- Create `apps/scheduler/lib/scheduler/service.ts`: server service facade. +- Create `apps/scheduler/lib/scheduler/auth.ts`: Authentik session guard. +- Create `apps/scheduler/lib/scheduler/legacy-request.ts`: converts App Router request data for Cal auth helper. +- Create `apps/scheduler/lib/scheduler/demo-data.ts`: deterministic fallback data used only when `SCHEDULER_DEMO_MODE=1`. +- Create `apps/scheduler/lib/scheduler/*.test.ts`: unit tests for privacy, time, and availability helpers. + +## Task 1: Scaffold the Scheduler App Shell + +**Files:** +- Create: `apps/scheduler/package.json` +- Create: `apps/scheduler/next.config.ts` +- Create: `apps/scheduler/tsconfig.json` +- Create: `apps/scheduler/next-env.d.ts` +- Create: `apps/scheduler/app/layout.tsx` +- Create: `apps/scheduler/app/page.tsx` +- Create: `apps/scheduler/app/globals.css` +- Create: `apps/scheduler/components/AppShell.tsx` + +- [ ] **Step 1: Create the workspace package** + +Create `apps/scheduler/package.json`: + +```json +{ + "name": "@vynte/scheduler", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "next dev --port 3040", + "build": "next build", + "start": "next start --port 3040", + "type-check": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@calcom/dayjs": "workspace:*", + "@calcom/features": "workspace:*", + "@calcom/lib": "workspace:*", + "@calcom/prisma": "workspace:*", + "@calcom/tsconfig": "workspace:*", + "@calcom/types": "workspace:*", + "next": "16.1.5", + "next-auth": "4.24.11", + "react": "19.2.4", + "react-dom": "19.2.4", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/node": "^20.17.23", + "@types/react": "18.0.26", + "@types/react-dom": "^18.0.9", + "typescript": "5.9.3", + "vitest": "4.1.8" + } +} +``` + +- [ ] **Step 2: Add Next and TypeScript config** + +Create `apps/scheduler/next.config.ts`: + +```ts +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: ["@calcom/dayjs", "@calcom/features", "@calcom/lib", "@calcom/prisma", "@calcom/types"], + experimental: { + externalDir: true, + }, +}; + +export default nextConfig; +``` + +Create `apps/scheduler/tsconfig.json`: + +```json +{ + "extends": "@calcom/tsconfig/nextjs.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@scheduler/*": ["./*"] + }, + "plugins": [{ "name": "next" }], + "strictNullChecks": true + }, + "include": [ + "next-env.d.ts", + "../../packages/types/*.d.ts", + "../../packages/types/next-auth.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": ["node_modules", ".next"] +} +``` + +Create `apps/scheduler/next-env.d.ts`: + +```ts +/// +/// +``` + +- [ ] **Step 3: Add layout, redirect, and global CSS** + +Create `apps/scheduler/app/layout.tsx`: + +```tsx +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Vynte Schedule", + description: "Internal Vynte team scheduling", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +Create `apps/scheduler/app/page.tsx`: + +```tsx +import { redirect } from "next/navigation"; + +export default function Page() { + redirect("/calendar"); +} +``` + +Create `apps/scheduler/app/globals.css` with the approved visual system: + +```css +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + background: #f4f5f7; + color: #202226; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, +input, +select, +textarea { + font: inherit; +} + +a { + color: inherit; + text-decoration: none; +} + +.app-frame { + min-height: 100vh; + display: grid; + grid-template-columns: 196px minmax(0, 1fr); + background: #f4f5f7; +} + +.sidebar { + min-height: 100vh; + border-right: 1px solid #e3e5e8; + background: #fafafa; + padding: 18px 12px; + display: flex; + flex-direction: column; +} + +.brand { + font-size: 16px; + font-weight: 750; + padding: 3px 8px 20px; +} + +.nav-list { + display: grid; + gap: 4px; +} + +.nav-item { + padding: 9px 10px; + border-radius: 5px; + color: #555a61; + font-size: 13px; + font-weight: 600; +} + +.nav-item[data-active="true"] { + background: #e9efec; + color: #204d39; +} + +.profile-block { + margin-top: auto; + border-top: 1px solid #e3e5e8; + padding: 14px 8px 2px; + font-size: 12px; +} + +.profile-block strong { + display: block; + font-size: 13px; + margin-bottom: 2px; +} + +.profile-block span { + color: #777c83; +} + +.main-surface { + padding: 20px; + min-width: 0; +} + +@media (max-width: 860px) { + .app-frame { + grid-template-columns: 1fr; + } + + .sidebar { + min-height: auto; + border-right: 0; + border-bottom: 1px solid #e3e5e8; + } +} +``` + +- [ ] **Step 4: Add shared shell** + +Create `apps/scheduler/components/AppShell.tsx`: + +```tsx +import Link from "next/link"; + +type NavItem = { + href: string; + label: string; +}; + +const navItems: NavItem[] = [ + { href: "/calendar", label: "Calendar" }, + { href: "/availability", label: "Availability" }, + { href: "/connections", label: "Connections" }, +]; + +export function AppShell({ + active, + user, + children, +}: { + active: "calendar" | "availability" | "connections"; + user: { name: string; timeZone: string }; + children: React.ReactNode; +}) { + return ( +
+ +
{children}
+
+ ); +} +``` + +- [ ] **Step 5: Verify scaffold** + +Run: + +```bash +node .yarn/releases/yarn-4.12.0.cjs workspace @vynte/scheduler type-check +``` + +Expected: TypeScript completes without errors. + +- [ ] **Step 6: Commit scaffold** + +```bash +git add apps/scheduler +git commit -m "Add Vynte scheduler app shell" +``` + +## Task 2: Implement Scheduler Domain Helpers + +**Files:** +- Create: `apps/scheduler/lib/scheduler/types.ts` +- Create: `apps/scheduler/lib/scheduler/time.ts` +- Create: `apps/scheduler/lib/scheduler/privacy.ts` +- Create: `apps/scheduler/lib/scheduler/availability.ts` +- Create: `apps/scheduler/lib/scheduler/privacy.test.ts` +- Create: `apps/scheduler/lib/scheduler/availability.test.ts` + +- [ ] **Step 1: Add shared domain types** + +Create `apps/scheduler/lib/scheduler/types.ts`: + +```ts +export type SchedulerUser = { + id: number; + name: string; + email: string; + timeZone: string; +}; + +export type BusyBlock = { + id: string; + userId: number; + start: string; + end: string; + title?: string; + source: "internal" | "external" | "availability"; +}; + +export type PublicBusyBlock = Pick & { + title?: string; +}; + +export type WeeklyRange = { + day: number; + enabled: boolean; + startTime: string; + endTime: string; +}; + +export type DateOverride = { + date: string; + unavailable: boolean; + startTime?: string; + endTime?: string; +}; + +export type SchedulerSchedule = { + id: number | null; + userId: number; + timeZone: string; + weekly: WeeklyRange[]; + overrides: DateOverride[]; +}; + +export type MutualSlot = { + start: string; + end: string; + attendeeIds: number[]; +}; + +export type MeetingDraft = { + title: string; + attendeeIds: number[]; + start: string; + durationMinutes: number; + location?: string; + conferencing?: "none" | "provider"; +}; +``` + +- [ ] **Step 2: Write failing privacy tests** + +Create `apps/scheduler/lib/scheduler/privacy.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { filterBusyBlocksForViewer } from "./privacy"; +import type { BusyBlock } from "./types"; + +const blocks: BusyBlock[] = [ + { + id: "own", + userId: 1, + start: "2026-06-15T16:00:00.000Z", + end: "2026-06-15T16:30:00.000Z", + title: "Product review", + source: "internal", + }, + { + id: "other", + userId: 2, + start: "2026-06-15T17:00:00.000Z", + end: "2026-06-15T17:30:00.000Z", + title: "Private vendor call", + source: "external", + }, +]; + +describe("filterBusyBlocksForViewer", () => { + it("keeps titles for the viewer's own blocks", () => { + expect(filterBusyBlocksForViewer(blocks, 1)[0]).toMatchObject({ title: "Product review" }); + }); + + it("removes titles for teammate blocks", () => { + expect(filterBusyBlocksForViewer(blocks, 1)[1]).not.toHaveProperty("title"); + }); +}); +``` + +- [ ] **Step 3: Implement privacy helper** + +Create `apps/scheduler/lib/scheduler/privacy.ts`: + +```ts +import type { BusyBlock, PublicBusyBlock } from "./types"; + +export function filterBusyBlocksForViewer(blocks: BusyBlock[], viewerId: number): PublicBusyBlock[] { + return blocks.map((block) => { + const base = { + id: block.id, + userId: block.userId, + start: block.start, + end: block.end, + source: block.source, + }; + + if (block.userId === viewerId && block.title) { + return { ...base, title: block.title }; + } + + return base; + }); +} +``` + +- [ ] **Step 4: Write failing mutual availability tests** + +Create `apps/scheduler/lib/scheduler/availability.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { computeMutualSlots } from "./availability"; +import type { BusyBlock, SchedulerSchedule } from "./types"; + +const schedules: SchedulerSchedule[] = [ + { + id: 1, + userId: 1, + timeZone: "America/Denver", + weekly: [{ day: 1, enabled: true, startTime: "09:00", endTime: "11:00" }], + overrides: [], + }, + { + id: 2, + userId: 2, + timeZone: "America/Denver", + weekly: [{ day: 1, enabled: true, startTime: "09:30", endTime: "11:30" }], + overrides: [], + }, +]; + +describe("computeMutualSlots", () => { + it("returns mutual slots that avoid busy blocks", () => { + const busy: BusyBlock[] = [ + { + id: "busy", + userId: 2, + start: "2026-06-15T16:00:00.000Z", + end: "2026-06-15T16:30:00.000Z", + source: "external", + }, + ]; + + const slots = computeMutualSlots({ + schedules, + busy, + rangeStart: "2026-06-15T00:00:00.000Z", + rangeEnd: "2026-06-16T00:00:00.000Z", + durationMinutes: 30, + }); + + expect(slots.map((slot) => slot.start)).toEqual([ + "2026-06-15T15:30:00.000Z", + "2026-06-15T16:30:00.000Z", + ]); + }); +}); +``` + +- [ ] **Step 5: Implement time and availability helpers** + +Create `apps/scheduler/lib/scheduler/time.ts`: + +```ts +export const MINUTE = 60_000; + +export function addMinutes(date: Date, minutes: number) { + return new Date(date.getTime() + minutes * MINUTE); +} + +export function overlaps(a: { start: Date; end: Date }, b: { start: Date; end: Date }) { + return a.start < b.end && b.start < a.end; +} + +export function toIso(date: Date) { + return date.toISOString(); +} +``` + +Create `apps/scheduler/lib/scheduler/availability.ts`: + +```ts +import { addMinutes, overlaps, toIso } from "./time"; +import type { BusyBlock, MutualSlot, SchedulerSchedule } from "./types"; + +type ComputeInput = { + schedules: SchedulerSchedule[]; + busy: BusyBlock[]; + rangeStart: string; + rangeEnd: string; + durationMinutes: number; +}; + +function minutesFromTime(value: string) { + const [hour, minute] = value.split(":").map(Number); + return hour * 60 + minute; +} + +function dateAtLocalMinutes(day: Date, minutes: number) { + const result = new Date(day); + result.setUTCHours(0, 0, 0, 0); + return addMinutes(result, minutes + 6 * 60); +} + +function windowsForSchedule(schedule: SchedulerSchedule, rangeStart: Date, rangeEnd: Date) { + const windows: { start: Date; end: Date; userId: number }[] = []; + for (let cursor = new Date(rangeStart); cursor < rangeEnd; cursor = addMinutes(cursor, 24 * 60)) { + const day = cursor.getUTCDay(); + const weekly = schedule.weekly.filter((range) => range.day === day && range.enabled); + for (const range of weekly) { + windows.push({ + userId: schedule.userId, + start: dateAtLocalMinutes(cursor, minutesFromTime(range.startTime)), + end: dateAtLocalMinutes(cursor, minutesFromTime(range.endTime)), + }); + } + } + return windows; +} + +export function computeMutualSlots(input: ComputeInput): MutualSlot[] { + const rangeStart = new Date(input.rangeStart); + const rangeEnd = new Date(input.rangeEnd); + const attendeeIds = input.schedules.map((schedule) => schedule.userId); + const windowsByUser = new Map( + input.schedules.map((schedule) => [schedule.userId, windowsForSchedule(schedule, rangeStart, rangeEnd)]) + ); + const slots: MutualSlot[] = []; + + for (let cursor = rangeStart; cursor < rangeEnd; cursor = addMinutes(cursor, input.durationMinutes)) { + const candidate = { start: cursor, end: addMinutes(cursor, input.durationMinutes) }; + const allWithinWindows = attendeeIds.every((userId) => + windowsByUser.get(userId)?.some((window) => window.start <= candidate.start && window.end >= candidate.end) + ); + if (!allWithinWindows) continue; + + const conflicts = input.busy.some((block) => + overlaps(candidate, { start: new Date(block.start), end: new Date(block.end) }) + ); + if (!conflicts) { + slots.push({ start: toIso(candidate.start), end: toIso(candidate.end), attendeeIds }); + } + } + + return slots; +} +``` + +- [ ] **Step 6: Run domain tests** + +Run: + +```bash +node .yarn/releases/yarn-4.12.0.cjs workspace @vynte/scheduler test apps/scheduler/lib/scheduler/privacy.test.ts apps/scheduler/lib/scheduler/availability.test.ts +``` + +Expected: both tests pass. + +- [ ] **Step 7: Commit domain helpers** + +```bash +git add apps/scheduler/lib/scheduler +git commit -m "Add scheduler availability domain helpers" +``` + +## Task 3: Implement Server Service and Scheduler APIs + +**Files:** +- Create: `apps/scheduler/lib/scheduler/legacy-request.ts` +- Create: `apps/scheduler/lib/scheduler/auth.ts` +- Create: `apps/scheduler/lib/scheduler/demo-data.ts` +- Create: `apps/scheduler/lib/scheduler/service.ts` +- Create: `apps/scheduler/app/api/scheduler/team/route.ts` +- Create: `apps/scheduler/app/api/scheduler/availability/route.ts` +- Create: `apps/scheduler/app/api/scheduler/schedule/route.ts` +- Create: `apps/scheduler/app/api/scheduler/connections/route.ts` +- Create: `apps/scheduler/app/api/scheduler/meetings/route.ts` + +- [ ] **Step 1: Add the legacy request adapter** + +Create `apps/scheduler/lib/scheduler/legacy-request.ts`: + +```ts +import type { NextApiRequest } from "next"; +import type { NextRequest } from "next/server"; + +export function buildLegacyRequest(request: NextRequest): NextApiRequest { + const cookies = Object.fromEntries(request.cookies.getAll().map((cookie) => [cookie.name, cookie.value])); + const headers = Object.fromEntries(request.headers.entries()); + + return { + cookies, + headers, + method: request.method, + url: request.nextUrl.pathname + request.nextUrl.search, + } as NextApiRequest; +} +``` + +- [ ] **Step 2: Add Authentik session guard** + +Create `apps/scheduler/lib/scheduler/auth.ts`: + +```ts +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { buildLegacyRequest } from "./legacy-request"; + +export async function getSchedulerSession(request: NextRequest) { + return getServerSession({ req: buildLegacyRequest(request) }); +} + +export async function requireSchedulerSession(request: NextRequest) { + const session = await getSchedulerSession(request); + if (!session?.user?.id) { + return { response: NextResponse.json({ error: "AUTHENTIK_LOGIN_REQUIRED" }, { status: 401 }) }; + } + + return { session }; +} +``` + +- [ ] **Step 3: Add deterministic demo data for explicit local mode** + +Create `apps/scheduler/lib/scheduler/demo-data.ts` with users, schedules, busy blocks, provider categories, and meetings. Export: + +```ts +export const demoUsers = [ + { id: 1, name: "Zach Sharma", email: "zach@vynte.local", timeZone: "America/Denver" }, + { id: 2, name: "Maya Chen", email: "maya@vynte.local", timeZone: "America/Denver" }, + { id: 3, name: "Jordan Lee", email: "jordan@vynte.local", timeZone: "America/Denver" }, +]; +``` + +The file must set Monday-Friday default weekly ranges and at least three busy blocks. + +- [ ] **Step 4: Add service facade** + +Create `apps/scheduler/lib/scheduler/service.ts` with these exports: + +```ts +export async function getTeamContext(viewerId: number) {} +export async function getSchedule(userId: number) {} +export async function updateSchedule(userId: number, body: unknown) {} +export async function getConnections(userId: number) {} +export async function getAvailability(viewerId: number, attendeeIds: number[], rangeStart: string, rangeEnd: string, durationMinutes: number) {} +export async function listMeetings(viewerId: number) {} +export async function createMeeting(viewerId: number, body: unknown) {} +``` + +Implementation rules: + +- If `process.env.SCHEDULER_DEMO_MODE === "1"`, use `demo-data.ts`. +- Otherwise read users from Prisma and include all unlocked users as the single team. +- For v1, `createMeeting` validates the attendee ids, organizer inclusion, title, start, and duration, then creates a minimal internal booking record only when the required existing Cal fields can be satisfied. If required Cal booking fields are not available, return `{ status: "not_implemented", reason: "CAL_BOOKING_SERVICE_BOUNDARY" }` with HTTP 501 from the route. +- `getAvailability` must call `filterBusyBlocksForViewer` and `computeMutualSlots`. +- `getConnections` must read `Credential` rows and return provider categories without credential secrets. + +- [ ] **Step 5: Add API route handlers** + +Each route must call `requireSchedulerSession(request)`. If it returns `response`, return that response. Then call the corresponding service method. + +Example for `apps/scheduler/app/api/scheduler/team/route.ts`: + +```ts +import { NextResponse, type NextRequest } from "next/server"; +import { requireSchedulerSession } from "@scheduler/lib/scheduler/auth"; +import { getTeamContext } from "@scheduler/lib/scheduler/service"; + +export async function GET(request: NextRequest) { + const auth = await requireSchedulerSession(request); + if ("response" in auth) return auth.response; + + return NextResponse.json(await getTeamContext(auth.session.user.id)); +} +``` + +Implement `GET` for team, availability, schedule, connections, and meetings. Implement `PUT` for schedule updates and `POST` for meeting creation. + +- [ ] **Step 6: Verify API type-check** + +Run: + +```bash +node .yarn/releases/yarn-4.12.0.cjs workspace @vynte/scheduler type-check +``` + +Expected: PASS. + +- [ ] **Step 7: Commit server/API layer** + +```bash +git add apps/scheduler/lib/scheduler apps/scheduler/app/api +git commit -m "Add scheduler API service layer" +``` + +## Task 4: Build Calendar, Availability, and Connections UI + +**Files:** +- Create: `apps/scheduler/components/CalendarWorkspace.tsx` +- Create: `apps/scheduler/components/AvailabilityEditor.tsx` +- Create: `apps/scheduler/components/ConnectionsView.tsx` +- Create: `apps/scheduler/app/calendar/page.tsx` +- Create: `apps/scheduler/app/availability/page.tsx` +- Create: `apps/scheduler/app/connections/page.tsx` +- Modify: `apps/scheduler/app/globals.css` + +- [ ] **Step 1: Add calendar workspace client component** + +Create `apps/scheduler/components/CalendarWorkspace.tsx` as a `"use client"` component. It must: + +- Render a five-day week grid. +- Render teammate busy blocks without private titles. +- Render own events with titles. +- Render mutual slots as outlined green blocks. +- Keep selected attendee ids in React state. +- Keep title, duration, optional location, and optional conferencing in React state. +- POST to `/api/scheduler/meetings` when Create meeting is clicked. + +The component props must be: + +```ts +type CalendarWorkspaceProps = { + viewerId: number; + team: SchedulerUser[]; + initialBusy: PublicBusyBlock[]; + initialSlots: MutualSlot[]; +}; +``` + +- [ ] **Step 2: Add availability editor client component** + +Create `apps/scheduler/components/AvailabilityEditor.tsx` as a `"use client"` component. It must: + +- Render seven weekly rows. +- Let each day be enabled/disabled. +- Let start/end times be edited with native ``. +- Render overrides from props. +- PUT updates to `/api/scheduler/schedule`. + +- [ ] **Step 3: Add connections server component** + +Create `apps/scheduler/components/ConnectionsView.tsx`. It must render: + +- Calendars panel. +- Conferencing panel. +- Provider rows with `Connected`, `Connect`, or `Browse` status. +- Privacy note: "Only availability blocks are visible to teammates." + +- [ ] **Step 4: Add pages** + +Create `apps/scheduler/app/calendar/page.tsx`. It must fetch team and availability server-side by calling service functions directly and render: + +```tsx + + + +``` + +Create `availability/page.tsx` and `connections/page.tsx` with the same `AppShell` pattern. + +- [ ] **Step 5: Extend global CSS** + +Add CSS classes matching the approved browser mockups: + +- `.calendar-layout` +- `.calendar-panel` +- `.week-grid` +- `.day-column` +- `.busy-block` +- `.mutual-slot` +- `.composer` +- `.settings-card` +- `.provider-row` +- `.primary-button` +- `.secondary-button` + +Use 8px or smaller radius, neutral borders, muted green accents, and stable dimensions. Do not add decorative gradients or marketing copy. + +- [ ] **Step 6: Verify UI type-check** + +Run: + +```bash +node .yarn/releases/yarn-4.12.0.cjs workspace @vynte/scheduler type-check +``` + +Expected: PASS. + +- [ ] **Step 7: Commit UI** + +```bash +git add apps/scheduler +git commit -m "Build Vynte scheduler screens" +``` + +## Task 5: Local Run, Browser Verification, and Deployment Hook + +**Files:** +- Modify: `docker-compose.yml` +- Modify: `portainer.env.example` +- Create: `apps/scheduler/Dockerfile` +- Modify: `docs/superpowers/specs/2026-06-14-vynte-scheduler-replacement-design.md` only if verification finds an approved spec correction. + +- [ ] **Step 1: Add scheduler Dockerfile** + +Create `apps/scheduler/Dockerfile` based on the root Dockerfile pattern but scoped to `@vynte/scheduler`. It must: + +- Build from `node:20`. +- Copy package metadata, `.yarn`, `apps/scheduler`, and `packages`. +- Run `yarn install`. +- Run `yarn workspace @vynte/scheduler build`. +- Expose `3040`. +- Start with `yarn workspace @vynte/scheduler start`. + +- [ ] **Step 2: Add optional Portainer service** + +Add a commented `scheduler` service block to `docker-compose.yml` using: + +```yaml + scheduler: + image: ${SCHEDULER_IMAGE:-10.0.3.6:4000/zachariahsharma/vynte-scheduler:latest} + restart: unless-stopped + ports: + - "${SCHEDULER_PORT:-3040}:3040" + environment: + NODE_ENV: production + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + NEXTAUTH_URL: ${NEXTAUTH_URL} + DATABASE_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom} + DATABASE_DIRECT_URL: postgresql://${POSTGRES_USER:-calcom}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-calcom} + CALENDSO_ENCRYPTION_KEY: ${CALENDSO_ENCRYPTION_KEY} +``` + +If comments inside the compose service would break parsing, place the service under a `profiles: ["scheduler"]` gate instead of commenting it out. + +- [ ] **Step 3: Add env examples** + +Add to `portainer.env.example`: + +```dotenv +SCHEDULER_IMAGE=10.0.3.6:4000/zachariahsharma/vynte-scheduler:latest +SCHEDULER_PORT=3040 +SCHEDULER_DEMO_MODE=0 +``` + +- [ ] **Step 4: Run tests and build** + +Run: + +```bash +node .yarn/releases/yarn-4.12.0.cjs workspace @vynte/scheduler test +node .yarn/releases/yarn-4.12.0.cjs workspace @vynte/scheduler type-check +``` + +Expected: PASS. + +- [ ] **Step 5: Start local dev server for Browser verification** + +Run: + +```bash +SCHEDULER_DEMO_MODE=1 NEXTAUTH_SECRET=temp node .yarn/releases/yarn-4.12.0.cjs workspace @vynte/scheduler dev +``` + +Open `http://localhost:3040/calendar` in Browser. Verify: + +- Calendar page renders. +- Attendee selection changes visible mutual slots. +- Meeting composer accepts title, duration, optional location, and optional conferencing. +- Availability page edits weekly hours without layout shift. +- Connections page renders calendar and conferencing provider panels. + +- [ ] **Step 6: Commit deployment hook and verification fixes** + +```bash +git add apps/scheduler docker-compose.yml portainer.env.example +git commit -m "Add scheduler deployment hook" +```