diff --git a/apps/scheduler/app/availability/page.tsx b/apps/scheduler/app/availability/page.tsx new file mode 100644 index 0000000000..6e317a2c25 --- /dev/null +++ b/apps/scheduler/app/availability/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from "next/navigation"; + +import { AppShell } from "@scheduler/components/AppShell"; +import { AvailabilityEditor } from "@scheduler/components/AvailabilityEditor"; +import { getServerViewerId } from "@scheduler/lib/scheduler/auth"; +import { getSchedule, getTeamContext } from "@scheduler/lib/scheduler/service"; + +export const dynamic = "force-dynamic"; + +export default async function AvailabilityPage() { + const viewerId = await getServerViewerId(); + if (viewerId === null) { + redirect("/auth/login"); + } + + const [context, schedule] = await Promise.all([getTeamContext(viewerId), getSchedule(viewerId)]); + + return ( + + + + ); +} diff --git a/apps/scheduler/app/calendar/page.tsx b/apps/scheduler/app/calendar/page.tsx new file mode 100644 index 0000000000..76a628c03b --- /dev/null +++ b/apps/scheduler/app/calendar/page.tsx @@ -0,0 +1,50 @@ +import { redirect } from "next/navigation"; + +import { AppShell } from "@scheduler/components/AppShell"; +import { CalendarWorkspace } from "@scheduler/components/CalendarWorkspace"; +import { getServerViewerId } from "@scheduler/lib/scheduler/auth"; +import { getAvailability, getTeamContext } from "@scheduler/lib/scheduler/service"; + +export const dynamic = "force-dynamic"; + +const DEFAULT_DURATION_MINUTES = 30; +const VISIBLE_DAYS = 5; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Monday 00:00 (server local) of the current week, and Monday + visible days. */ +function currentWeekRange(): { rangeStart: string; rangeEnd: string } { + const start = new Date(); + start.setHours(0, 0, 0, 0); + const weekday = start.getDay(); + const mondayOffset = weekday === 0 ? -6 : 1 - weekday; + start.setDate(start.getDate() + mondayOffset); + return { + rangeStart: start.toISOString(), + rangeEnd: new Date(start.getTime() + VISIBLE_DAYS * DAY_MS).toISOString(), + }; +} + +export default async function CalendarPage() { + const viewerId = await getServerViewerId(); + if (viewerId === null) { + redirect("/auth/login"); + } + + const { rangeStart, rangeEnd } = currentWeekRange(); + const [context, availability] = await Promise.all([ + getTeamContext(viewerId), + getAvailability(viewerId, [viewerId], rangeStart, rangeEnd, DEFAULT_DURATION_MINUTES), + ]); + + return ( + + + + ); +} diff --git a/apps/scheduler/app/connections/page.tsx b/apps/scheduler/app/connections/page.tsx new file mode 100644 index 0000000000..ec99c3ccdd --- /dev/null +++ b/apps/scheduler/app/connections/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from "next/navigation"; + +import { AppShell } from "@scheduler/components/AppShell"; +import { ConnectionsView } from "@scheduler/components/ConnectionsView"; +import { getServerViewerId } from "@scheduler/lib/scheduler/auth"; +import { getConnections, getTeamContext } from "@scheduler/lib/scheduler/service"; + +export const dynamic = "force-dynamic"; + +export default async function ConnectionsPage() { + const viewerId = await getServerViewerId(); + if (viewerId === null) { + redirect("/auth/login"); + } + + const [context, { connections }] = await Promise.all([getTeamContext(viewerId), getConnections(viewerId)]); + + return ( + + + + ); +} diff --git a/apps/scheduler/app/globals.css b/apps/scheduler/app/globals.css index 64eff6b132..851f5d94f5 100644 --- a/apps/scheduler/app/globals.css +++ b/apps/scheduler/app/globals.css @@ -13,3 +13,67 @@ a { color: inherit; text-decoration: none; } .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; } } + +/* Shared primitives */ +.panel-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 16px; } +.panel-header h1 { font-size: 20px; font-weight: 700; margin: 0; } +.panel-header h2, .panel-header h1 + * { margin: 0; } +.muted { color: #777c83; font-size: 13px; } +.primary-button { background: #246b48; color: #fff; border: 1px solid #1f5c3e; border-radius: 6px; padding: 9px 16px; font-size: 13px; font-weight: 600; cursor: pointer; } +.primary-button:disabled { opacity: 0.6; cursor: default; } +.secondary-button { background: #fff; color: #333; border: 1px solid #d7dadf; border-radius: 6px; padding: 7px 12px; font-size: 13px; font-weight: 600; cursor: pointer; } +.secondary-button[data-active="true"] { border-color: #246b48; color: #204d39; background: #e9efec; } +.notice { font-size: 13px; margin: 8px 0 0; } +.notice-error { color: #a23434; } +.notice-warn { color: #8a6320; } +.notice-ok { color: #246b48; } +.field { display: grid; gap: 4px; margin: 12px 0; font-size: 13px; } +.field input { border: 1px solid #d7dadf; border-radius: 6px; padding: 8px 10px; } +.field-inline { display: flex; align-items: center; gap: 8px; font-size: 13px; margin: 12px 0; } + +/* Calendar */ +.calendar-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 18px; align-items: start; } +.calendar-panel { background: #fff; border: 1px solid #e3e5e8; border-radius: 8px; padding: 16px; } +.week-grid { display: grid; grid-template-columns: 56px repeat(5, minmax(0, 1fr)); gap: 0; } +.time-gutter { padding-top: 26px; } +.hour-label { font-size: 11px; color: #9aa0a6; border-top: 1px solid #f0f1f3; padding: 2px 4px; } +.day-column { border-left: 1px solid #f0f1f3; min-width: 0; } +.day-heading { font-size: 12px; font-weight: 600; text-align: center; padding: 6px 0; color: #555a61; } +.day-body { position: relative; background: #fff; } +.busy-block { position: absolute; left: 3px; right: 3px; background: #e7e9ec; border: 1px solid #d3d6db; border-radius: 4px; font-size: 11px; padding: 2px 4px; overflow: hidden; color: #555a61; } +.busy-block[data-own="true"] { background: #dce6e0; border-color: #b9cdc2; color: #2d5a42; } +.mutual-slot { position: absolute; left: 3px; right: 3px; background: rgba(36, 107, 72, 0.12); border: 1px solid #246b48; border-radius: 4px; font-size: 11px; color: #204d39; cursor: pointer; padding: 2px 4px; } +.mutual-slot[data-selected="true"] { background: #246b48; color: #fff; } + +/* Composer */ +.composer { background: #fff; border: 1px solid #e3e5e8; border-radius: 8px; padding: 16px; position: sticky; top: 20px; } +.composer h2 { font-size: 16px; margin: 0 0 12px; } +.composer-group { border: 1px solid #eceef0; border-radius: 6px; padding: 10px 12px; margin: 0 0 12px; } +.composer-group legend { font-size: 12px; font-weight: 600; color: #555a61; padding: 0 4px; } +.attendee-row { display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 3px 0; } +.duration-presets { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.duration-custom { width: 64px; border: 1px solid #d7dadf; border-radius: 6px; padding: 7px 8px; } +.selected-slot { font-size: 13px; margin: 8px 0 12px; } + +/* Availability + settings cards */ +.availability-layout, .connections-layout { display: grid; gap: 16px; max-width: 640px; } +.settings-card { background: #fff; border: 1px solid #e3e5e8; border-radius: 8px; padding: 16px; } +.settings-card h2 { font-size: 16px; margin: 0 0 12px; } +.weekly-rows { display: grid; gap: 6px; } +.weekly-row { display: flex; align-items: center; gap: 16px; padding: 6px 0; border-top: 1px solid #f0f1f3; } +.weekly-row[data-enabled="false"] { opacity: 0.7; } +.weekly-toggle { min-width: 130px; } +.time-range { display: flex; align-items: center; gap: 8px; } +.time-range input { border: 1px solid #d7dadf; border-radius: 6px; padding: 6px 8px; } +.dash { color: #9aa0a6; } +.unavailable-label { font-style: italic; } +.override-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.override-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px solid #f0f1f3; font-size: 13px; } +.save-bar { display: flex; align-items: center; gap: 12px; } + +/* Connections */ +.provider-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid #f0f1f3; } +.provider-name { font-size: 13px; font-weight: 600; } +.provider-status.connected { font-size: 12px; color: #246b48; font-weight: 600; } +.browse-button { display: inline-block; margin-top: 12px; } +.privacy-note { font-size: 12px; color: #777c83; } diff --git a/apps/scheduler/components/AvailabilityEditor.tsx b/apps/scheduler/components/AvailabilityEditor.tsx new file mode 100644 index 0000000000..81bd96b060 --- /dev/null +++ b/apps/scheduler/components/AvailabilityEditor.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import type { DateOverride, SchedulerSchedule, WeeklyRange } from "@scheduler/lib/scheduler/types"; + +type AvailabilityEditorProps = { + schedule: SchedulerSchedule; +}; + +const DAY_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const; +const DEFAULT_START = "09:00"; +const DEFAULT_END = "17:00"; + +type SaveState = + | { kind: "idle" } + | { kind: "saving" } + | { kind: "saved" } + | { kind: "error"; message: string }; + +/** Builds one editable row per weekday, seeded from the schedule's first range for that day. */ +function buildRows(weekly: WeeklyRange[]): WeeklyRange[] { + return DAY_LABELS.map((_, day) => { + const existing = weekly.find((range) => range.day === day && range.enabled); + return { + day, + enabled: Boolean(existing), + startTime: existing?.startTime ?? DEFAULT_START, + endTime: existing?.endTime ?? DEFAULT_END, + }; + }); +} + +export function AvailabilityEditor({ schedule }: AvailabilityEditorProps) { + const [rows, setRows] = useState(() => buildRows(schedule.weekly)); + const [overrides, setOverrides] = useState(schedule.overrides); + const [saveState, setSaveState] = useState({ kind: "idle" }); + const timeZone = schedule.timeZone; + + const enabledCount = useMemo(() => rows.filter((row) => row.enabled).length, [rows]); + + const updateRow = (day: number, patch: Partial) => { + setRows((current) => current.map((row) => (row.day === day ? { ...row, ...patch } : row))); + }; + + const removeOverride = (date: string) => { + setOverrides((current) => current.filter((override) => override.date !== date)); + }; + + const save = async () => { + setSaveState({ kind: "saving" }); + try { + const response = await fetch("/api/scheduler/schedule", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + timeZone, + weekly: rows.filter((row) => row.enabled), + overrides, + }), + }); + if (!response.ok) throw new Error("save failed"); + const updated = (await response.json()) as SchedulerSchedule; + setRows(buildRows(updated.weekly)); + setOverrides(updated.overrides); + setSaveState({ kind: "saved" }); + } catch { + setSaveState({ kind: "error", message: "Could not save your schedule." }); + } + }; + + return ( +
+
+

Availability

+ Timezone: {timeZone} +
+ +
+

Weekly hours

+

+ {enabledCount} day{enabledCount === 1 ? "" : "s"} available +

+
+ {rows.map((row) => ( +
+ + {row.enabled ? ( +
+ updateRow(row.day, { startTime: event.target.value })} + aria-label={`${DAY_LABELS[row.day]} start`} + /> + + updateRow(row.day, { endTime: event.target.value })} + aria-label={`${DAY_LABELS[row.day]} end`} + /> +
+ ) : ( + Unavailable + )} +
+ ))} +
+
+ +
+

Date overrides

+ {overrides.length === 0 ? ( +

No date overrides.

+ ) : ( +
    + {overrides.map((override) => ( +
  • + {override.date} + + {override.unavailable + ? "Unavailable all day" + : `${override.startTime ?? DEFAULT_START}–${override.endTime ?? DEFAULT_END}`} + + +
  • + ))} +
+ )} +
+ +
+ + {saveState.kind === "saved" && Saved.} + {saveState.kind === "error" && {saveState.message}} +
+
+ ); +} diff --git a/apps/scheduler/components/CalendarWorkspace.tsx b/apps/scheduler/components/CalendarWorkspace.tsx new file mode 100644 index 0000000000..a070cb6057 --- /dev/null +++ b/apps/scheduler/components/CalendarWorkspace.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { MutualSlot, PublicBusyBlock, SchedulerUser } from "@scheduler/lib/scheduler/types"; + +type CalendarWorkspaceProps = { + viewerId: number; + team: SchedulerUser[]; + initialBusy: PublicBusyBlock[]; + initialSlots: MutualSlot[]; + /** Server-computed Monday 00:00 ISO of the visible week — shared so the first + * client fetch range matches the data rendered on the server. */ + weekStartIso: string; +}; + +const DURATION_PRESETS = [15, 30, 45, 60] as const; +const DAY_START_HOUR = 7; +const DAY_END_HOUR = 19; +const PX_PER_HOUR = 48; +const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"] as const; +const REFETCH_DEBOUNCE_MS = 300; + +type CreateState = + | { kind: "idle" } + | { kind: "saving" } + | { kind: "error"; message: string } + | { kind: "unavailable"; message: string } + | { kind: "created" }; + +/** Monday 00:00 (local) of the week containing `from`. */ +function startOfWeek(from: Date): Date { + const date = new Date(from); + date.setHours(0, 0, 0, 0); + const weekday = date.getDay(); // 0 = Sun + const mondayOffset = weekday === 0 ? -6 : 1 - weekday; + date.setDate(date.getDate() + mondayOffset); + return date; +} + +function addDays(date: Date, days: number): Date { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +function minutesIntoDay(iso: string): number { + const date = new Date(iso); + return date.getHours() * 60 + date.getMinutes(); +} + +function sameDay(iso: string, day: Date): boolean { + const date = new Date(iso); + return ( + date.getFullYear() === day.getFullYear() && + date.getMonth() === day.getMonth() && + date.getDate() === day.getDate() + ); +} + +function offsetTop(iso: string): number { + const minutes = minutesIntoDay(iso) - DAY_START_HOUR * 60; + return (minutes / 60) * PX_PER_HOUR; +} + +function blockHeight(startIso: string, endIso: string): number { + const minutes = (new Date(endIso).getTime() - new Date(startIso).getTime()) / 60_000; + return Math.max((minutes / 60) * PX_PER_HOUR, 14); +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + +export function CalendarWorkspace({ + viewerId, + team, + initialBusy, + initialSlots, + weekStartIso, +}: CalendarWorkspaceProps) { + const weekStart = useMemo(() => startOfWeek(new Date(weekStartIso)), [weekStartIso]); + const days = useMemo(() => WEEKDAYS.map((_, index) => addDays(weekStart, index)), [weekStart]); + const hours = useMemo(() => { + const list: number[] = []; + for (let hour = DAY_START_HOUR; hour < DAY_END_HOUR; hour += 1) list.push(hour); + return list; + }, []); + + const [selectedAttendeeIds, setSelectedAttendeeIds] = useState([viewerId]); + const [busy, setBusy] = useState(initialBusy); + const [slots, setSlots] = useState(initialSlots); + const [durationMinutes, setDurationMinutes] = useState(30); + const [title, setTitle] = useState(""); + const [location, setLocation] = useState(""); + const [conferencing, setConferencing] = useState(false); + const [selectedSlotStart, setSelectedSlotStart] = useState(null); + const [createState, setCreateState] = useState({ kind: "idle" }); + const [loading, setLoading] = useState(false); + const isFirstRender = useRef(true); + + const rangeStart = days[0].toISOString(); + const rangeEnd = addDays(weekStart, WEEKDAYS.length).toISOString(); + + const toggleAttendee = useCallback( + (id: number) => { + if (id === viewerId) return; // organizer always attends + setSelectedAttendeeIds((current) => + current.includes(id) ? current.filter((value) => value !== id) : [...current, id] + ); + }, + [viewerId] + ); + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + + const controller = new AbortController(); + const timer = setTimeout(async () => { + setLoading(true); + try { + const params = new URLSearchParams({ + attendeeIds: selectedAttendeeIds.join(","), + rangeStart, + rangeEnd, + durationMinutes: String(durationMinutes), + }); + const response = await fetch(`/api/scheduler/availability?${params.toString()}`, { + signal: controller.signal, + }); + if (!response.ok) throw new Error("availability request failed"); + const data = (await response.json()) as { busy: PublicBusyBlock[]; mutualSlots: MutualSlot[] }; + setBusy(data.busy); + setSlots(data.mutualSlots); + setSelectedSlotStart(null); + } catch (error) { + if (!controller.signal.aborted) { + setCreateState({ kind: "error", message: "Could not refresh availability." }); + } + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }, REFETCH_DEBOUNCE_MS); + + return () => { + controller.abort(); + clearTimeout(timer); + }; + }, [selectedAttendeeIds, durationMinutes, rangeStart, rangeEnd]); + + const createMeeting = useCallback(async () => { + if (!selectedSlotStart || !title.trim()) { + setCreateState({ kind: "error", message: "Pick a slot and enter a title first." }); + return; + } + + setCreateState({ kind: "saving" }); + try { + const response = await fetch("/api/scheduler/meetings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: title.trim(), + attendeeIds: selectedAttendeeIds, + start: selectedSlotStart, + durationMinutes, + location: location.trim() || undefined, + conferencing: conferencing ? "provider" : "none", + }), + }); + + if (response.status === 501) { + setCreateState({ + kind: "unavailable", + message: "Meeting booking is not wired up yet (CAL_BOOKING_SERVICE_BOUNDARY).", + }); + return; + } + if (response.status === 409) { + setCreateState({ kind: "unavailable", message: "That slot is no longer available." }); + return; + } + if (!response.ok) throw new Error("create failed"); + setCreateState({ kind: "created" }); + } catch { + setCreateState({ kind: "error", message: "Could not create the meeting." }); + } + }, [selectedSlotStart, title, selectedAttendeeIds, durationMinutes, location, conferencing]); + + return ( +
+
+
+

Calendar

+ {loading ? "Updating availability…" : "Mutual free slots outlined"} +
+
+
+ {hours.map((hour) => ( +
+ {hour}:00 +
+ ))} +
+ {days.map((day, index) => ( +
+
+ {WEEKDAYS[index]} {day.getMonth() + 1}/{day.getDate()} +
+
+ {busy + .filter((block) => sameDay(block.start, day)) + .map((block) => { + // Teammate blocks never reveal a title, even if one leaks + // past the server-side privacy filter. + const label = block.userId === viewerId ? block.title ?? "Busy" : "Busy"; + return ( +
+ {label} +
+ ); + })} + {slots + .filter((slot) => sameDay(slot.start, day)) + .map((slot) => ( + + ))} +
+
+ ))} +
+
+ + +
+ ); +} diff --git a/apps/scheduler/components/ConnectionsView.tsx b/apps/scheduler/components/ConnectionsView.tsx new file mode 100644 index 0000000000..a7e49a9040 --- /dev/null +++ b/apps/scheduler/components/ConnectionsView.tsx @@ -0,0 +1,70 @@ +import type { SchedulerConnection } from "@scheduler/lib/scheduler/service"; + +type ConnectionsViewProps = { + connections: SchedulerConnection[]; +}; + +const CONFERENCING_CATEGORIES = new Set(["conferencing", "video"]); + +function providerLabel(connection: SchedulerConnection): string { + return connection.appId ?? connection.type; +} + +function ProviderRow({ connection }: { connection: SchedulerConnection }) { + return ( +
+ {providerLabel(connection)} + {connection.connected ? ( + Connected + ) : ( + + Connect + + )} +
+ ); +} + +export function ConnectionsView({ connections }: ConnectionsViewProps) { + const calendars = connections.filter((connection) => connection.category === "calendar"); + const conferencing = connections.filter((connection) => CONFERENCING_CATEGORIES.has(connection.category)); + + return ( +
+
+

Connections

+ Reuses every provider supported by Cal. +
+ +
+

Calendars

+ {calendars.length === 0 ? ( +

No calendars connected yet.

+ ) : ( + calendars.map((connection) => ( + + )) + )} + + Browse calendar providers + +
+ +
+

Conferencing

+ {conferencing.length === 0 ? ( +

No conferencing providers connected yet.

+ ) : ( + conferencing.map((connection) => ( + + )) + )} + + Browse conferencing providers + +
+ +

Only availability blocks are visible to teammates.

+
+ ); +} diff --git a/apps/scheduler/lib/scheduler/auth.ts b/apps/scheduler/lib/scheduler/auth.ts index 2dc864e3cd..8c739aae33 100644 --- a/apps/scheduler/lib/scheduler/auth.ts +++ b/apps/scheduler/lib/scheduler/auth.ts @@ -1,13 +1,47 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { cookies, headers } from "next/headers"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { buildLegacyRequest } from "./legacy-request"; +/** Viewer id used for the demo team's organizer when SCHEDULER_DEMO_MODE=1. */ +const DEMO_VIEWER_ID = 1; + +/** + * Demo mode bypasses Authentik and impersonates the organizer, so it must never + * be honored in production even if the env var is accidentally left set. + */ +function isDemoMode(): boolean { + return process.env.SCHEDULER_DEMO_MODE === "1" && process.env.NODE_ENV !== "production"; +} + export async function getSchedulerSession(request: NextRequest) { return getServerSession({ req: buildLegacyRequest(request) }); } +/** + * Resolves the current viewer id inside a Server Component (which has no + * `NextRequest`). Returns the demo organizer in demo mode, otherwise reads the + * Authentik session from request cookies/headers. Returns null when there is no + * valid session so the caller can redirect to login. + */ +export async function getServerViewerId(): Promise { + if (isDemoMode()) { + return DEMO_VIEWER_ID; + } + + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]); + const req = { + cookies: Object.fromEntries(cookieStore.getAll().map((cookie) => [cookie.name, cookie.value])), + headers: Object.fromEntries(headerStore.entries()), + } as unknown as Parameters[0]["req"]; + + const session = await getServerSession({ req }); + const viewerId = Number(session?.user?.id); + return Number.isInteger(viewerId) && viewerId > 0 ? viewerId : null; +} + /** * Returns either `{ session, userId }` for an authenticated viewer, or `{ response }` * carrying a 401 the route handler should return directly. The `userId` is the