docs: plan vynte scheduler replacement

This commit is contained in:
2026-06-14 11:05:33 -06:00
parent 4982d80306
commit 8a941061a4
@@ -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
/// <reference types="next" />
/// <reference types="next/image-types/global" />
```
- [ ] **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 (
<html lang="en">
<body>{children}</body>
</html>
);
}
```
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 (
<div className="app-frame">
<aside className="sidebar">
<div className="brand">Vynte Schedule</div>
<nav className="nav-list" aria-label="Scheduler navigation">
{navItems.map((item) => (
<Link
key={item.href}
className="nav-item"
data-active={item.label.toLowerCase() === active}
href={item.href}>
{item.label}
</Link>
))}
</nav>
<div className="profile-block">
<strong>{user.name}</strong>
<span>{user.timeZone}</span>
</div>
</aside>
<main className="main-surface">{children}</main>
</div>
);
}
```
- [ ] **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<BusyBlock, "id" | "userId" | "start" | "end" | "source"> & {
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 `<input type="time">`.
- 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
<AppShell active="calendar" user={{ name: context.viewer.name, timeZone: context.viewer.timeZone }}>
<CalendarWorkspace
viewerId={context.viewer.id}
team={context.team}
initialBusy={availability.busy}
initialSlots={availability.mutualSlots}
/>
</AppShell>
```
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"
```