docs: plan vynte scheduler replacement
This commit is contained in:
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user