Module kit / Calendar and booking
Calendar Settings Panel
calendar-booking/ui/calendar-settings-panellive
| Kind | frontend |
|---|---|
| Surface | SPA |
| Maturity | prod |
| Reusable | extract |
| Design system | alchemy-ui |
| Stack | react-19, react-router-dom-7, tailwindcss-3.4, alchemy-ui, axios-1, lucide-react-0.563 |
| Depends on | _shared/design-system-and-tokens, _shared/react-app-skeleton, _shared/error-envelope |
| Renders | calendar-booking/calendar-connection, calendar-booking/meeting-type-configuration, calendar-booking/availability-computation, calendar-booking/booking-confirmation-flow, calendar-booking/embed-widget-delivery |
| Entry | alchemy_labs/frontend/src/pages/CalendarSettings.tsx |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
The operator's own calendar page: connect a Google Calendar (or, workspace-side, an iCal feed or CalDAV
calendar without OAuth), define the bookable meeting types customers embed on their site, and see who has
booked, is booking today, or already met. The invariant that makes it trustworthy: every card is a straight
read of the three GET /api/calendar/{workspace|admin}/* endpoints this page calls on mount, so what an
operator sees here - whether Google is actually connected, which meeting types actually exist, whose booking
actually landed - is never a locally-computed guess; every write (connect, disconnect, save or delete a meeting
type, save the default messages) is followed by the same refetch, the same pattern billing-credits/ui/
subscription-manager documents for its own balance.
It makes visible four of its five sibling backend recipes directly - the OAuth/iCal/CalDAV connect flow
(calendar-connection), the meeting-type CRUD and its default/per-type message inheritance
(meeting-type-configuration), the confirmation/reminder/cancellation side of a booking once it exists
(booking-confirmation-flow), and the <iframe> code this page hands the operator to paste on their site
(embed-widget-delivery) - plus, indirectly, the fifth (availability-computation): the schedule this page
lets an operator type in per weekday is the input that recipe turns into bookable slots on the public embed,
which this page never itself computes or previews.
When to use
Trigger phrases: "the calendar page", "connect Google Calendar", "meeting types", "the embed code", "who's
booked today", "Kalender-Einstellungen", "Kalender-Integration". Surface: SPA, two private routes behind two
different guards - /calendar (PrivateRoute, any signed-in workspace member, "Bookings" domain) and
/admin/calendar (AdminRoute, platform admins only, meant for the "Operate" domain).
Not this recipe: the public booking page a meeting type's embed code actually opens (/api/calendar/embed/ {slug}, server-rendered, no React here, embed-widget-delivery's territory); the OAuth popup's own screens
(Google's, not Labs'); the day-by-day slot computation the operator never sees rendered
(availability-computation); the confirmation/reminder/follow-up e-mails a booking triggers
(booking-confirmation-flow); the actual message-step editor a click on either Nachrichten toggle would open
(MessageStepsEditor, the forms domain, a different recipe entirely, Frontend); the iCal/CalDAV tab forms
(AlternativeCalendarConnect, Frontend).
Stack assumptions
S1 tokens and S2 skeleton assumed; pinned per the source at 62ba4fc: react 19.1, react-router-dom 7.8,
tailwindcss 3.4, lucide-react 0.563, axios 1.11. Component library: @prima-modules/alchemy-ui, of
which the page uses Button, Heading, Text, Card, Input, Textarea, Spinner, Badge and Toggle
Toggleis genuinely new to this domain versus billing-credits/ui/subscription-manager's atom list. Two lucide icons are imported and never drawn in the workspace file:User(line 22) andCopy(line 15, the workspace embed button usesCode/Check-Copyis the admin file's icon for the same button, Gotchas). Data-fetch style: theapisingleton (S2), Bearer header fromaccessToken. Copy language: German (de-CH) throughout both files, with one real, unfixed asymmetry: the workspace file'sLOCATION_TYPESlabels "Telefon" and "Vor Ort" in German where the admin file's identical-looking list (same four values) labels the same two "Phone Call" and "In Person" in English (workspace 98-99, admin 91-92).
Data model
Client state, all in the page component, nothing persisted beyond S2's two tokens:
interface CalendarIntegration { // GET .../integration, unchanged from the route's JSON
id: string; workspace_id: string | null; provider: string; // 'google' | 'ical_feed' | 'caldav'
provider_account_id: string | null; provider_account_name: string | null;
calendar_id: string; timezone: string; is_active: boolean;
connected_at: string | null; last_sync_at: string | null; is_platform_level: boolean;
}
interface MeetingType {
id: string; calendar_integration_id: string; slug: string; name: string; description: string | null;
duration_minutes: number; buffer_before_minutes: number; buffer_after_minutes: number;
min_notice_hours: number; max_days_ahead: number; availability_schedule: Record<string, string[]>;
location_type: string; custom_location: string | null; zoom_link: string | null; phone_number: string | null;
color: string; booking_questions: any[]; embed_settings: Record<string, any>; is_active: boolean;
messages: any[] | null; // workspace scope ONLY; absent from the admin interface
created_at: string | null;
}
interface Booking {
id: string; meeting_type_id: string; starts_at: string; ends_at: string;
attendee_email: string; attendee_name: string; meeting_link: string | null; location: string | null;
status: string; google_event_link: string | null;
}
// workspace file only:
integration, meetingTypes, bookings, isLoading, isConnecting, message, showPastBookings: boolean;
showMeetingTypeForm, editingMeetingType, meetingTypeForm (name/slug/description/duration_minutes/
buffer_before_minutes/buffer_after_minutes/min_notice_hours/max_days_ahead/location_type/custom_location/
zoom_link/phone_number/color/availability_schedule/messages), isSavingMeetingType;
defaultMessages: any[] | null, isSavingDefaults, defaultsLoaded; // calendar-wide Nachrichten default, new
// since 2e1b47d (see BRAIN.md's session log)
expandedMeetingType, copiedId;
categorizedBookings = useMemo(() => { today, upcoming, past }, [bookings]); // reads new Date() live, Gotchas
The demo's props (demo.tsx): { viewer, workspace, scope: 'workspace' | 'platform-admin', status: 'loading' | 'ok', integration, meetingTypes, bookings, now, message?, isConnecting?, showPastBookings?, defaultMessages?, showMeetingTypeForm?, editingMeetingType?, meetingTypeForm?, isSavingMeetingType?, expandedMeetingTypeId? };
now replaces the source's live new Date() read inside categorizedBookings (Frontend, Gotchas) so the demo
categorises bookings deterministically across a server render and a later client hydration. showPastBookings,
defaultMessages, showMeetingTypeForm, the form fields and expandedMeetingTypeId all seed local useState
in the demo the same way billing-credits/ui/subscription-manager seeds its voucher input, so the toggles, the
form and the row-expand chevron are genuinely interactive in the docs frame even though nothing reaches a
network.
Browser storage: nothing of its own beyond S2's two tokens.
API surface
Contracts consumed, one shared read pattern and five writes, workspace paths on the left, the admin file's own
separate mounts on the right (calendar_router.py, both under LABS_SURFACES):
| Call | Workspace route | Admin route | Auth / tenancy | Rendered as |
|---|---|---|---|---|
| read | GET /workspace/integration?workspace_id= | GET /admin/integration | workspace member vs AdminRoute | the connection-status card |
| read | GET /workspace/meeting-types | GET /admin/meeting-types | same | the meeting-types list |
| read | GET /workspace/bookings | GET /admin/bookings?limit=10 | same | the bookings section (admin: server-capped at 10, no client-side categorisation to hide the cap) |
| read | GET /workspace/integration/default-messages | - (no admin equivalent) | same | the Standard-Nachrichten toggle's seed |
| write | POST /workspace/connect | POST /admin/connect | same | OAuth popup opened with the response's authorization_url |
| write | DELETE /workspace/disconnect | DELETE /admin/disconnect | same | integration cleared client-side on 200 |
| write | POST / PUT /workspace/meeting-types[/:id] | POST / PUT /admin/meeting-types[/:id] | same | the form closes, fetchData() refetches |
| write | DELETE /workspace/meeting-types/:id | DELETE /admin/meeting-types/:id | same | row removed from local state directly (no refetch) |
| write | PUT /workspace/integration/default-messages | - | same | success/error message card |
| write | PATCH /workspace/integration/timezone | - | same | integration's timezone patched client-side; failure is console.error only, no user-facing message at all (Gotchas) |
Error bodies as both files read them: every refusal from these routes is HTTPException(detail=...)
(calendar_router.py), wrapped by app_fastapi.py's handler (line 719-767 at 62ba4fc) into the Labs envelope
under the key error, not detail (create_error_response, error_schemas.py line 130). Both files' six
catch blocks read error.response?.data?.detail - the same mismatch billing-credits/ui/subscription-manager
and identity-auth/ui/admin-users-console document for their own pages - so every specific server message
("workspace_id is required to connect a calendar", "No calendar connected. Connect Google, iCal, or CalDAV
first.", "Meeting type with slug 'x' already exists", and so on) is discarded and replaced by the page's own
fixed fallback sentence. No WebSocket. window.addEventListener('message', ...) listens for the OAuth popup's
postMessage({type: 'CALENDAR_CONNECTED', success, error}), checked against isTrustedMessageOrigin before
use in both files.
Application logic
- Workspace file only: on mount, guards for
isLoadingWorkspacesand a workspace-less account render before any calendar UI (lines 500-544, out of this recipe's scope -identity-auth/ui/workspace-switcher-and- bootstrap's territory). fetchData()(workspace 174-212, admin 151-177) fires once per file's own mount effect:GET .../ integrationfirst; if it returns an integration, meeting types and bookings are fetched in parallel (workspace additionally fetchesdefault-messagesin the samePromise.all, its own.then/.catchsettingdefaultMessages/defaultsLoadedindependently of the other two calls - a deliberate positional ordering the source comments warn about, lines 188-189, so a future fourth call must be appended last, not inserted).googleConnected(workspace 559) isintegration?.provider === 'google'- an iCal or CalDAV integration still unlocks meeting types below but renders the connection card as "nicht verbunden". The admin file has no such distinction: it only ever connects Google, sointegrationtruthy is enough (admin has no iCal/ CalDAV path at all -AlternativeCalendarConnectis workspace-only).- Connect: opens a popup to the response's
authorization_url; a blocked popup sets an error and re-enables the button; the popup's own eventualpostMessageis what actually flipsisConnectingback off and refetches (step 2's listener), not theconnectcall's own response. - Disconnect:
window.confirmfirst (both files, same German sentence); on confirm, clearsintegrationandmeetingTypesclient-side (admin also clearsbookings, workspace does not - a minor, real asymmetry) and shows a success card, no refetch needed since the cleared state already matches "disconnected". - Meeting-type save:
custom_location/zoom_link/phone_numberare nulled server-side unlesslocation_typematches (both files, same three-line spread); on success the form resets andfetchData()refetches; on failure the fixed fallback sentence appears and the form stays open with whatever was typed. - Meeting-type delete:
window.confirmfirst; on confirm, the row is filtered out of local state directly - no refetch, so a delete that raced a concurrent edit by someone else would not be caught here. - Standard-Nachrichten (workspace only): the
ToggleswitchesdefaultMessagesbetweennull(inherit nothing, calendar sends nothing by default) and[](on, empty - the operator has not written a step yet); the actual step content is edited byMessageStepsEditor, not part of this recipe (Frontend). Saving PUTs the whole array; the comment on screen is explicit that a save applies to new bookings only, not ones already scheduled (line 727-729). - Per-meeting-type Nachrichten (workspace only, inside the form): the same
null/[]toggle, butnullhere means "inherit the calendar's Standard-Nachrichten" rather than "send nothing" - the two toggles share a shape but not a meaning, and the form's own comment (lines 1049-1051) is the only place that says so. copyEmbedCodewritesgenerateEmbedCode(slug, integration?.id)(workspace) orgenerateEmbedCode(slug)with no integration id (admin,calendarEmbed.ts'sintegrationIdparam is optional) to the clipboard and flips a 2-second checkmark; the admin embed URL is therefore not disambiguated by integration id the way the workspace one is, harmless today since a platform admin has exactly one platform-level integration.- Bookings:
categorizedBookings(workspace only, admin has no categorisation) filters outstatus === 'cancelled', then buckets by comparing eachstarts_at's calendar day againstnew Date()'s; today and upcoming sort ascending, past sorts descending and is sliced to 10 behind a collapsed toggle, default closed.
Invariants
- Every card is a refetch away from the server, never a locally-computed write: connect, disconnect, save and
delete a meeting type all either refetch (
fetchData()) or splice local state to match what the server just confirmed (steps 5, 7) rather than optimistically rendering an assumed result. - A meeting type's own Nachrichten and the calendar's Standard-Nachrichten are two independently toggled
null/[]states with two different meanings fornull(step 9) - a genuinely easy invariant to break by accident in a rebuild, since both toggles look and read identically. - Deleting a meeting type does not refetch, only filters local state (step 7); a rebuild that adds optimistic UI elsewhere should not assume this page already refetches after every write - it does not, uniformly.
- The raw server error is never shown on either file, only one of six fixed fallback sentences, or nothing at all for a failed timezone patch (API surface, Gotchas); an operator never learns why a save, a connect or a disconnect actually failed.
Frontend
The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.
loading server rendered, hydrated in view disconnected server rendered, hydrated in view connected-empty server rendered, hydrated in view populated server rendered, hydrated in view meeting-type-form-open server rendered, hydrated in view admin-variant server rendered, hydrated in view calendar-booking/ui/calendar-settings-panel/demo.tsx
/**
* Live demo for calendar-booking/ui/calendar-settings-panel.
*
* Primary source: Labs' CalendarSettings.tsx (branch main, 62ba4fc, 1369
* lines) — the workspace-scoped operator page at `/calendar`, mounted under
* `PrivateRoute` ("Bookings" domain, nav.tsx line 133, `App.tsx` line 455).
* Its one read (`fetchData`, three parallel GETs) and five writes (connect,
* disconnect, save/delete a meeting type, save the calendar-wide default
* messages) are replaced by props; the meeting-type form and the default-
* messages toggle keep their own local `useState`, seeded from props, the
* same convention identity-auth/ui/admin-users-console's demo uses for its
* search box and billing-credits/ui/subscription-manager's for its voucher
* input, so typing and toggling in the docs frame still works.
*
* The `admin-variant` state approximates a SECOND, genuinely separate real
* file: `AdminCalendarSettings.tsx` (969 lines), the platform-admin page at
* `/admin/calendar` (`AdminRoute`, `App.tsx` line 799, "Operate" domain).
* It is not a prop branch of one source file the way, say, an admin flag
* changes one Labs page elsewhere — it is its own component, its own fetch
* calls (`/api/calendar/admin/*`, no `workspace_id`), and a `MeetingType`
* type with no `messages` field at all. This recipe reproduces the real,
* measured differences (named below and in Gotchas) rather than inventing a
* single unified admin/workspace branch that does not exist in Labs; the
* admin file is a secondary `reference_impl` entry, not the one this recipe
* is measured against pixel-for-pixel.
*
* What is kept byte-faithful (workspace scope, the primary source): the
* header, the connection-status card's copy and layout, the meeting-types
* list row (color bar, duration/location/notice/buffer summary line, the
* embed-code box always visible under the row), the meeting-type form's
* field set and its one genuine gap (Data model / Gotchas), the
* Standard-Nachrichten toggle, the bookings section's today/upcoming/past
* categorisation with the pulsing "today" summary banner and the collapsed
* past list sliced to 10, and every string of copy, German (de-CH),
* including the two dead icon imports (`User`, `Copy`) the source carries
* and this demo does not re-import.
*
* What is different, and why:
* - `new Date()` (the source's `categorizedBookings` useMemo, line 454) is
* replaced by an injected `now` prop: a server-rendered frame and a later
* client hydration of the same demo could otherwise categorise a booking
* into "today" on one render and "past" on the other if a real clock
* crossed midnight between them. Sample data is dated around `now` so the
* three buckets are non-empty.
* - `toLocaleString('de-CH', …)` / `toLocaleDateString('de-CH', …)` (lines
* 414-450) are replaced with fixed formatters reading UTC getters against
* a lookup table, the same class of fix identity-auth/ui/admin-users-
* console's demo applies to `toLocaleString()` and this domain's sibling
* billing-credits/ui/subscription-manager applies to `toLocaleDateString`:
* the browser's ICU data does not have to match Node's for the frame to
* render the same text twice.
* - `AlternativeCalendarConnect` (the iCal/CalDAV card, `disconnected`
* state): its header row is reproduced exactly (same `Card`, same copy,
* same closed-by-default `Button`), because that row is what the real
* page shows before a click. Its open state — two tab forms that call
* `GET /api/calendar/workspace/integrations` on mount and POST a feed URL
* or CalDAV credentials — is not rendered: it is a separate component, not
* part of the four packages a demo may import from (AUTHORING.md), and it
* reaches the network on mount, which this demo must not do.
* - `PrimaMachtsButton` (the AI-companion button beside "Neu" in the
* workspace file, `usePrima`/`PrimaContext`-dependent) is not rendered,
* the same way billing-credits/ui/subscription-manager does not mount
* `SubscriptionModal`: it needs a provider this kit does not carry.
* - `MessageStepsEditor` (the actual message-step editor, `forms` domain,
* not part of the four packages) is not mounted. The Standard-Nachrichten
* and per-meeting-type Nachrichten `Toggle`s are real and interactive
* (both are `@prima-modules/alchemy-ui` `Toggle`s, nothing page-local);
* when a toggle is on, a read-only summary row per configured step (its
* type as a `Badge`, its timing as plain text) stands in for the editor.
* - `window.confirm` before disconnect/delete and `window.open` for the
* OAuth popup are not wired — a demo has no user to confirm with and
* reaches no network — the same convention subscription-manager's demo
* uses for its own `window.confirm`/`window.location.href`. The
* `window.addEventListener('message', …)` OAuth-callback listener has
* nothing to listen to without a real popup, so it is not attached either.
* - `navigator.clipboard.writeText` on "copy embed code" is not called; the
* copied-checkmark swap it drives is reproduced from local state alone, so
* clicking it in the docs frame still shows the checkmark.
* - Real, structural admin-vs-workspace differences reproduced in
* `admin-variant`: no Standard-Nachrichten section and no per-type
* Nachrichten toggle (the admin `MeetingType` interface has no `messages`
* field, AdminCalendarSettings.tsx lines 57-70); no `AlternativeCalendar
* Connect` card (admin is Google-only); the "Neu" button reads "Neuer
* Meeting-Typ" (admin line 502) not "Neu"; each meeting-type row expands
* on a chevron click into a details grid instead of always showing the
* embed box (admin lines 856-905); an "Inaktiv" `Badge` on a disabled
* meeting type (admin has this, workspace has no such badge); English
* location labels ("Phone Call", "In Person", admin lines 91-96) against
* the workspace file's German ones ("Telefon", "Vor Ort") — a real,
* unfixed inconsistency between the two files, reproduced not corrected;
* bookings render as a flat "Aktuelle Buchungen" list with a raw status
* `Badge` (admin lines 912-967) instead of the workspace file's
* today/upcoming/past breakdown. The admin meeting-type FORM itself
* (which does add address/phone fields the workspace form lacks, drops
* the timezone selector for static text, and caps buffers/lookahead
* lower) is not rendered here — no state opens it, so it is documented in
* this recipe's Gotchas and Reference implementation rather than built,
* the same restraint billing-credits/ui/subscription-manager applies to
* `SubscriptionModal`'s own body.
* - Sample data (workspace name, attendee names and e-mails, calendar
* account name, every id) is fictional; no id from a real Labs workspace,
* Google account or booking is reproduced (AUTHORING.md house rules).
*/
import React, { useState } from 'react';
import {
Calendar,
Check,
ChevronDown,
ChevronUp,
Clock,
Code,
Copy,
Edit,
ExternalLink,
Link2,
Link2Off,
MapPin,
Phone,
Plus,
RefreshCw,
Trash2,
Video,
} from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Badge, Button, Card, Heading, Input, Spinner, Text, Textarea, Toggle } from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';
/* ---- types (CalendarSettings.tsx lines 43-93, AdminCalendarSettings.tsx lines 37-79) ---------- */
export interface CalendarIntegration {
id: string;
workspace_id: string | null;
provider: string;
provider_account_id: string | null;
provider_account_name: string | null;
calendar_id: string;
timezone: string;
is_active: boolean;
connected_at: string | null;
last_sync_at: string | null;
is_platform_level: boolean;
}
export interface MessageStep {
id: string;
type: 'confirmation' | 'reminder' | 'followup';
enabled: boolean;
timing?: { anchor: 'on_event' | 'before_event' | 'after_event'; value: number; unit: 'minutes' | 'hours' | 'days' | 'weeks' };
}
export interface MeetingType {
id: string;
calendar_integration_id: string;
slug: string;
name: string;
description: string | null;
duration_minutes: number;
buffer_before_minutes: number;
buffer_after_minutes: number;
min_notice_hours: number;
max_days_ahead: number;
availability_schedule: Record<string, string[]>;
location_type: string;
custom_location: string | null;
zoom_link: string | null;
phone_number: string | null;
color: string;
is_active: boolean;
/** Workspace scope only: null = inherit the calendar default; [] = deliberately none; [...] = own.
* The admin `MeetingType` interface has no such field (AdminCalendarSettings.tsx 57-70) — left
* undefined for an admin-scope row. */
messages?: MessageStep[] | null;
created_at: string | null;
}
export interface Booking {
id: string;
meeting_type_id: string;
starts_at: string;
ends_at: string;
attendee_email: string;
attendee_name: string;
meeting_link: string | null;
location: string | null;
status: string;
google_event_link: string | null;
}
export interface MeetingTypeFormState {
name: string;
slug: string;
description: string;
duration_minutes: number;
buffer_before_minutes: number;
buffer_after_minutes: number;
min_notice_hours: number;
max_days_ahead: number;
location_type: string;
custom_location: string;
zoom_link: string;
phone_number: string;
color: string;
availability_schedule: Record<string, string[]>;
messages: MessageStep[] | null;
}
export interface Props {
/** The signed-in operator, for the shell's account row. `isAdmin` also picks the shell's rail. */
viewer: ShellUser;
workspace: ShellWorkspace;
/** Which real file this state approximates: CalendarSettings.tsx (workspace) or AdminCalendarSettings.tsx (platform-admin). */
scope: 'workspace' | 'platform-admin';
/** `loading` is the first fetch (line 546-554, a bare spinner inside the shell); `ok` is any state after. */
status: 'loading' | 'ok';
integration: CalendarIntegration | null;
meetingTypes: MeetingType[];
bookings: Booking[];
/** Fixed "now" the bookings categorisation reads instead of `new Date()` (see header comment). */
now: string;
message?: { type: 'success' | 'error'; text: string } | null;
isConnecting?: boolean;
showPastBookings?: boolean;
/** Workspace scope only. `null` seeds the toggle off; a non-null array (possibly empty) seeds it on. */
defaultMessages?: MessageStep[] | null;
showMeetingTypeForm?: boolean;
editingMeetingType?: MeetingType | null;
meetingTypeForm?: MeetingTypeFormState;
isSavingMeetingType?: boolean;
/** Admin scope only: which row's details panel starts expanded. */
expandedMeetingTypeId?: string | null;
}
/* ---- constants (CalendarSettings.tsx 95-120, AdminCalendarSettings.tsx 87-113) ---------------- */
/** The workspace file's labels (German). The admin file's are English (below) — a real, unfixed inconsistency. */
const LOCATION_TYPES_WORKSPACE = [
{ value: 'google_meet', label: 'Google Meet', icon: Video },
{ value: 'zoom', label: 'Zoom', icon: Video },
{ value: 'phone', label: 'Telefon', icon: Phone },
{ value: 'in_person', label: 'Vor Ort', icon: MapPin },
];
const LOCATION_TYPES_ADMIN = [
{ value: 'google_meet', label: 'Google Meet', icon: Video },
{ value: 'zoom', label: 'Zoom', icon: Video },
{ value: 'phone', label: 'Phone Call', icon: Phone },
{ value: 'in_person', label: 'In Person', icon: MapPin },
];
const DAY_LABELS: Record<string, string> = {
mon: 'Montag', tue: 'Dienstag', wed: 'Mittwoch', thu: 'Donnerstag', fri: 'Freitag', sat: 'Samstag', sun: 'Sonntag',
};
const DEFAULT_SCHEDULE: Record<string, string[]> = {
mon: ['09:00-17:00'], tue: ['09:00-17:00'], wed: ['09:00-17:00'], thu: ['09:00-17:00'], fri: ['09:00-17:00'], sat: [], sun: [],
};
const MESSAGE_TYPE_LABEL: Record<MessageStep['type'], string> = { confirmation: 'Bestätigung', reminder: 'Erinnerung', followup: 'Follow-up' };
function formatTiming(step: MessageStep): string {
if (!step.timing) return '';
const { anchor, value, unit } = step.timing;
const UNIT_LABEL: Record<string, string> = { minutes: 'Minuten', hours: 'Stunden', days: 'Tage', weeks: 'Wochen' };
if (anchor === 'on_event') return 'bei Terminbeginn';
const dir = anchor === 'before_event' ? 'vorher' : 'danach';
return `${value} ${UNIT_LABEL[unit]} ${dir}`;
}
/* ---- date helpers (replace the source's locale-dependent formatting; see header comment) ------ */
const WEEKDAY_SHORT_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const pad = (n: number) => String(n).padStart(2, '0');
function formatTime(iso: string): string {
const d = new Date(iso);
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
function formatDateTime(iso: string): string {
const d = new Date(iso);
return `${pad(d.getUTCDate())}.${pad(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}, ${formatTime(iso)}`;
}
function utcDayStart(iso: string): number {
const d = new Date(iso);
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}
function formatDateShort(iso: string, nowIso: string): string {
const dayStart = utcDayStart(iso);
const today = utcDayStart(nowIso);
const tomorrow = today + 86_400_000;
if (dayStart === today) return 'Heute';
if (dayStart === tomorrow) return 'Morgen';
const d = new Date(iso);
return `${WEEKDAY_SHORT_DE[d.getUTCDay()]}, ${pad(d.getUTCDate())}.${pad(d.getUTCMonth() + 1)}.`;
}
/** Port of CalendarSettings.tsx's categorizedBookings useMemo (lines 453-483), `now` injected. */
function categorizeBookings(bookings: Booking[], nowIso: string) {
const todayStart = utcDayStart(nowIso);
const tomorrowStart = todayStart + 86_400_000;
const today: Booking[] = [];
const upcoming: Booking[] = [];
const past: Booking[] = [];
const active = bookings.filter((b) => b.status !== 'cancelled');
for (const b of active) {
const start = utcDayStart(b.starts_at);
if (start < todayStart) past.push(b);
else if (start < tomorrowStart) today.push(b);
else upcoming.push(b);
}
today.sort((a, b) => +new Date(a.starts_at) - +new Date(b.starts_at));
upcoming.sort((a, b) => +new Date(a.starts_at) - +new Date(b.starts_at));
past.sort((a, b) => +new Date(b.starts_at) - +new Date(a.starts_at));
return { today, upcoming, past };
}
function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[äöü]/g, (m) => ({ ä: 'ae', ö: 'oe', ü: 'ue' } as Record<string, string>)[m] || m)
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
/* ---- sample data ----------------------------------------------------------------------------- */
const viewer: ShellUser = { name: 'Talia Wenger', email: 'talia@example.com', isAdmin: false };
const adminViewer: ShellUser = { name: 'Dario Keller', email: 'dario@example.com', isAdmin: true };
const workspace: ShellWorkspace = { id: 'ws_42', name: 'Studio Feuerstein', slug: 'studio-feuerstein' };
const NOW = '2026-09-12T08:00:00Z';
const googleIntegration: CalendarIntegration = {
id: 'cal_int_1',
workspace_id: 'ws_42',
provider: 'google',
provider_account_id: 'acct_gcal_sample_1',
provider_account_name: 'kalender@studio-feuerstein.example',
calendar_id: 'primary',
timezone: 'Europe/Zurich',
is_active: true,
connected_at: '2026-08-03T09:12:00Z',
last_sync_at: '2026-09-12T07:40:00Z',
is_platform_level: false,
};
const platformIntegration: CalendarIntegration = {
...googleIntegration,
id: 'cal_int_platform_1',
workspace_id: null,
provider_account_name: 'kalender@prima-platform.example',
is_platform_level: true,
};
const meetingTypesSample: MeetingType[] = [
{
id: 'mt_1', calendar_integration_id: 'cal_int_1', slug: 'erstgespraech', name: 'Erstgespräch',
description: 'Kurzes Kennenlernen für neue Anfragen.', duration_minutes: 30,
buffer_before_minutes: 0, buffer_after_minutes: 15, min_notice_hours: 24, max_days_ahead: 28,
availability_schedule: { ...DEFAULT_SCHEDULE }, location_type: 'google_meet', custom_location: null,
zoom_link: null, phone_number: null, color: '#6844C4', is_active: true, messages: null,
created_at: '2026-08-05T10:00:00Z',
},
{
id: 'mt_2', calendar_integration_id: 'cal_int_1', slug: 'strategie-call', name: 'Strategie-Call',
description: 'Vertiefte Beratung für bestehende Kund:innen.', duration_minutes: 60,
buffer_before_minutes: 10, buffer_after_minutes: 15, min_notice_hours: 48, max_days_ahead: 45,
availability_schedule: { ...DEFAULT_SCHEDULE, sat: ['10:00-12:00'] }, location_type: 'zoom',
custom_location: null, zoom_link: 'https://zoom.us/j/00000000000', phone_number: null,
color: '#2E8B57', is_active: true, messages: [
{ id: 'msg_1', type: 'confirmation', enabled: true, timing: { anchor: 'on_event', value: 0, unit: 'days' } },
{ id: 'msg_2', type: 'reminder', enabled: true, timing: { anchor: 'before_event', value: 24, unit: 'hours' } },
],
created_at: '2026-08-11T14:30:00Z',
},
{
id: 'mt_3', calendar_integration_id: 'cal_int_1', slug: 'vor-ort-termin', name: 'Vor-Ort-Termin',
description: null, duration_minutes: 45,
buffer_before_minutes: 15, buffer_after_minutes: 15, min_notice_hours: 72, max_days_ahead: 21,
availability_schedule: { ...DEFAULT_SCHEDULE }, location_type: 'in_person',
custom_location: 'Bahnhofstrasse 12, 8001 Zürich', zoom_link: null, phone_number: null,
color: '#C4844C', is_active: true, messages: [],
created_at: '2026-08-20T08:15:00Z',
},
];
const adminMeetingTypesSample: MeetingType[] = [
{
id: 'mt_admin_1', calendar_integration_id: 'cal_int_platform_1', slug: 'onboarding-call', name: 'Onboarding-Call',
description: 'Erstgespräch für neue Plattform-Kund:innen.', duration_minutes: 30,
buffer_before_minutes: 0, buffer_after_minutes: 15, min_notice_hours: 24, max_days_ahead: 28,
availability_schedule: { ...DEFAULT_SCHEDULE }, location_type: 'google_meet', custom_location: null,
zoom_link: null, phone_number: null, color: '#6844C4', is_active: true,
created_at: '2026-07-14T09:00:00Z',
},
{
id: 'mt_admin_2', calendar_integration_id: 'cal_int_platform_1', slug: 'partner-check-in', name: 'Partner-Check-in',
description: 'Vierteljährliches Review mit Agentur-Partnern.', duration_minutes: 45,
buffer_before_minutes: 10, buffer_after_minutes: 10, min_notice_hours: 48, max_days_ahead: 60,
availability_schedule: { ...DEFAULT_SCHEDULE }, location_type: 'phone', custom_location: null,
zoom_link: null, phone_number: '+41 44 000 00 00', color: '#8C8C8C', is_active: false,
created_at: '2026-06-02T11:00:00Z',
},
];
const bookingsSample: Booking[] = [
{ id: 'bk_1', meeting_type_id: 'mt_1', starts_at: '2026-09-12T09:00:00Z', ends_at: '2026-09-12T09:30:00Z', attendee_email: 'lea.furrer@example.com', attendee_name: 'Lea Furrer', meeting_link: 'https://meet.example.com/erstgespraech-lea', location: null, status: 'confirmed', google_event_link: 'https://calendar.example.com/event/1' },
{ id: 'bk_2', meeting_type_id: 'mt_2', starts_at: '2026-09-12T13:00:00Z', ends_at: '2026-09-12T14:00:00Z', attendee_email: 'noah.meier@example.com', attendee_name: 'Noah Meier', meeting_link: 'https://zoom.us/j/00000000000', location: null, status: 'confirmed', google_event_link: 'https://calendar.example.com/event/2' },
{ id: 'bk_3', meeting_type_id: 'mt_1', starts_at: '2026-09-13T10:00:00Z', ends_at: '2026-09-13T10:30:00Z', attendee_email: 'sina.baumann@example.com', attendee_name: 'Sina Baumann', meeting_link: null, location: null, status: 'confirmed', google_event_link: 'https://calendar.example.com/event/3' },
{ id: 'bk_4', meeting_type_id: 'mt_3', starts_at: '2026-09-16T15:00:00Z', ends_at: '2026-09-16T15:45:00Z', attendee_email: 'jonas.rieder@example.com', attendee_name: 'Jonas Rieder', meeting_link: null, location: 'Bahnhofstrasse 12, 8001 Zürich', status: 'confirmed', google_event_link: null },
{ id: 'bk_5', meeting_type_id: 'mt_2', starts_at: '2026-09-10T09:00:00Z', ends_at: '2026-09-10T10:00:00Z', attendee_email: 'mira.suter@example.com', attendee_name: 'Mira Suter', meeting_link: null, location: null, status: 'confirmed', google_event_link: 'https://calendar.example.com/event/5' },
{ id: 'bk_cancelled', meeting_type_id: 'mt_1', starts_at: '2026-09-14T09:00:00Z', ends_at: '2026-09-14T09:30:00Z', attendee_email: 'declined@example.com', attendee_name: 'Elias Roth', meeting_link: null, location: null, status: 'cancelled', google_event_link: null },
// 11 more past bookings, literal and fictional, so the collapsed list demonstrates its "+N weitere" overflow (source line 1341).
...Array.from({ length: 11 }, (_, i) => ({
id: `bk_past_${i}`,
meeting_type_id: i % 2 === 0 ? 'mt_1' : 'mt_2',
starts_at: `2026-08-${String(20 - i).padStart(2, '0')}T09:00:00Z`,
ends_at: `2026-08-${String(20 - i).padStart(2, '0')}T09:30:00Z`,
attendee_email: `kontakt${i}@example.com`,
attendee_name: `Kontakt ${i + 1}`,
meeting_link: null,
location: null,
status: 'confirmed',
google_event_link: null,
})),
];
const adminBookingsSample: Booking[] = [
{ id: 'bk_admin_1', meeting_type_id: 'mt_admin_1', starts_at: '2026-09-12T11:00:00Z', ends_at: '2026-09-12T11:30:00Z', attendee_email: 'partner1@example.com', attendee_name: 'Agentur Nordlicht', meeting_link: null, location: null, status: 'confirmed', google_event_link: 'https://calendar.example.com/event/a1' },
{ id: 'bk_admin_2', meeting_type_id: 'mt_admin_2', starts_at: '2026-09-15T14:00:00Z', ends_at: '2026-09-15T14:45:00Z', attendee_email: 'partner2@example.com', attendee_name: 'Kollektiv Aare', meeting_link: null, location: null, status: 'pending', google_event_link: null },
{ id: 'bk_admin_3', meeting_type_id: 'mt_admin_1', starts_at: '2026-09-08T09:00:00Z', ends_at: '2026-09-08T09:30:00Z', attendee_email: 'partner3@example.com', attendee_name: 'Studio Winkel', meeting_link: null, location: null, status: 'cancelled', google_event_link: null },
];
const defaultMeetingTypeForm: MeetingTypeFormState = {
name: '', slug: '', description: '', duration_minutes: 60, buffer_before_minutes: 0, buffer_after_minutes: 15,
min_notice_hours: 24, max_days_ahead: 28, location_type: 'google_meet', custom_location: '', zoom_link: '',
phone_number: '', color: '#6844C4', availability_schedule: { ...DEFAULT_SCHEDULE }, messages: null,
};
const base: Props = {
viewer,
workspace,
scope: 'workspace',
status: 'ok',
integration: googleIntegration,
meetingTypes: meetingTypesSample,
bookings: bookingsSample,
now: NOW,
message: null,
isConnecting: false,
showPastBookings: false,
defaultMessages: [
{ id: 'default_1', type: 'confirmation', enabled: true, timing: { anchor: 'on_event', value: 0, unit: 'days' } },
{ id: 'default_2', type: 'reminder', enabled: true, timing: { anchor: 'before_event', value: 24, unit: 'hours' } },
],
showMeetingTypeForm: false,
editingMeetingType: null,
meetingTypeForm: defaultMeetingTypeForm,
isSavingMeetingType: false,
};
export const states = defineStates<Props>({
loading: {
label: 'First fetch: the bare spinner inside the shell, before the integration is known (line 546-554)',
props: { ...base, status: 'loading', integration: null, meetingTypes: [], bookings: [] },
height: 560, viewport: 1440,
},
disconnected: {
label: 'No calendar connected: the Verbinden card plus the closed iCal/CalDAV alternative-connect header',
props: { ...base, integration: null, meetingTypes: [], bookings: [], defaultMessages: null },
height: 620, viewport: 1440,
},
'connected-empty': {
label: 'Google connected, no meeting types or bookings yet: the two empty-state cards, Standard-Nachrichten off',
props: { ...base, meetingTypes: [], bookings: [], defaultMessages: null },
height: 1100, viewport: 1440,
},
populated: {
label: 'Three meeting types, bookings across today/upcoming/past (collapsed), Standard-Nachrichten on',
props: base,
height: 2400, viewport: 1440,
},
'meeting-type-form-open': {
label: 'Creating a meeting type: the form open over the existing list, per-type Nachrichten inheriting the default',
props: { ...base, showMeetingTypeForm: true, meetingTypeForm: { ...defaultMeetingTypeForm, name: 'Discovery Call', slug: 'discovery-call' } },
height: 2800, viewport: 1440,
},
'admin-variant': {
label: 'Platform-admin at /admin/calendar: no Standard-Nachrichten, one row expanded, a flat Aktuelle-Buchungen list',
props: {
...base,
viewer: adminViewer,
scope: 'platform-admin',
integration: platformIntegration,
meetingTypes: adminMeetingTypesSample,
bookings: adminBookingsSample,
defaultMessages: null,
expandedMeetingTypeId: 'mt_admin_1',
},
height: 1500, viewport: 1440,
},
});
/* ---- the page (Kalender/Kalender-Integration) ---------------------------------------------- */
type PageProps = Omit<Props, 'viewer' | 'workspace'>;
function CalendarPanel({
scope,
status,
integration,
meetingTypes,
bookings,
now,
message = null,
isConnecting = false,
showPastBookings: showPastBookingsInitial = false,
defaultMessages: defaultMessagesInitial = null,
showMeetingTypeForm: showMeetingTypeFormInitial = false,
editingMeetingType = null,
meetingTypeForm: meetingTypeFormInitial = defaultMeetingTypeForm,
isSavingMeetingType = false,
expandedMeetingTypeId: expandedInitial = null,
}: PageProps) {
const [showPastBookings, setShowPastBookings] = useState(showPastBookingsInitial);
const [defaultMessages, setDefaultMessages] = useState<MessageStep[] | null>(defaultMessagesInitial);
const [showMeetingTypeForm, setShowMeetingTypeForm] = useState(showMeetingTypeFormInitial);
const [form, setForm] = useState<MeetingTypeFormState>(meetingTypeFormInitial);
const [expandedMeetingTypeId, setExpandedMeetingTypeId] = useState<string | null>(expandedInitial);
const [copiedId, setCopiedId] = useState<string | null>(null);
const isAdmin = scope === 'platform-admin';
const LOCATION_TYPES = isAdmin ? LOCATION_TYPES_ADMIN : LOCATION_TYPES_WORKSPACE;
const locationLabel = (value: string) => LOCATION_TYPES.find((lt) => lt.value === value)?.label;
if (status === 'loading') {
return (
<div className="flex items-center justify-center h-[480px]">
<Spinner size="xl" color="primary" label="Laden..." />
</div>
);
}
const googleConnected = isAdmin ? !!integration : integration?.provider === 'google';
const { today, upcoming, past } = categorizeBookings(bookings, now);
return (
<div className={`${isAdmin ? 'max-w-6xl' : 'max-w-4xl'} mx-auto p-6`}>
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<Heading level={1} className="mb-2 flex items-center gap-3">
<Calendar className="w-8 h-8 text-[#6844C4]" />
{isAdmin ? 'Kalender-Integration' : 'Kalender'}
</Heading>
<Text muted>
{isAdmin
? 'Google Calendar verbinden und Meeting-Typen für das Onboarding verwalten'
: 'Verbinde deinen Google Calendar und erstelle buchbare Termine'}
</Text>
</div>
{integration && (
<Button variant="ghost" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Aktualisieren
</Button>
)}
</div>
{message && (
<Card variant="glass" padding="md" className={`mb-6 ${message.type === 'success' ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<Text className={message.type === 'success' ? 'text-green-800' : 'text-red-800'}>{message.text}</Text>
</Card>
)}
{/* Connection status */}
<Card variant="glass" padding="lg" className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${googleConnected ? 'bg-green-100' : 'bg-stone-100'}`}>
{googleConnected ? <Link2 className="w-6 h-6 text-green-600" /> : <Link2Off className="w-6 h-6 text-ink-60" />}
</div>
<div>
<Text weight="semibold" size="lg">{googleConnected ? 'Google Calendar verbunden' : 'Google Calendar nicht verbunden'}</Text>
{googleConnected && integration && (
<Text muted size="sm">
{integration.provider_account_name} ({integration.provider_account_id})
{isAdmin && integration.connected_at && <span className="ml-2">• Verbunden am {formatDateTime(integration.connected_at)}</span>}
</Text>
)}
</div>
</div>
<div>
{googleConnected ? (
<Button variant="outline" size="sm">
<Link2Off className="w-4 h-4 mr-2" />
Trennen
</Button>
) : (
<Button variant="primary" size="sm" disabled={isConnecting}>
<Link2 className="w-4 h-4 mr-2" />
{isAdmin ? 'Google Calendar verbinden' : 'Verbinden'}
</Button>
)}
</div>
</div>
</Card>
{/* Alternative (iCal/CalDAV) connect — workspace only, and only while Google is not the active provider. */}
{!isAdmin && !googleConnected && (
<Card variant="glass" padding="lg" className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-stone-100">
<Calendar className="w-6 h-6 text-ink-60" />
</div>
<div>
<Text weight="semibold" size="lg">Andere Kalender (iCal · CalDAV)</Text>
<Text muted size="sm">Hostpoint, Fastmail, mailbox.org, ProtonCalendar — ohne OAuth.</Text>
</div>
</div>
<Button variant="outline" size="sm">Verbinden</Button>
</div>
</Card>
)}
{/* Standard-Nachrichten — workspace only */}
{!isAdmin && integration && (
<Card className="mb-6">
<div className="flex items-start justify-between gap-4 mb-3">
<div>
<Heading level={2}>Standard-Nachrichten</Heading>
<Text size="sm" muted className="mt-1">
Gelten für alle Meeting-Typen, die keine eigenen Nachrichten haben. Platzhalter: <code>{'{{name}}'}</code>, <code>{'{{meeting}}'}</code>, <code>{'{{date}}'}</code>, <code>{'{{time}}'}</code>, <code>{'{{location}}'}</code>.
</Text>
</div>
<Toggle checked={defaultMessages !== null} onChange={(on: boolean) => setDefaultMessages(on ? [] : null)} />
</div>
{defaultMessages === null ? (
<Text size="sm" muted>Keine Standard-Nachrichten. Einschalten, um eine Bestätigung, eine Erinnerung oder ein Follow-up für alle Termine zu schreiben.</Text>
) : (
<MessageStepsSummary steps={defaultMessages} />
)}
</Card>
)}
{/* Meeting types */}
{integration && (
<>
<div className="mb-4 flex items-center justify-between">
<Heading level={2}>Meeting-Typen</Heading>
<Button variant="primary" size="sm" onClick={() => setShowMeetingTypeForm(true)}>
<Plus className="w-4 h-4 mr-2" />
{isAdmin ? 'Neuer Meeting-Typ' : 'Neu'}
</Button>
</div>
{!isAdmin && showMeetingTypeForm && (
<MeetingTypeForm
form={form}
setForm={setForm}
editing={!!editingMeetingType}
isSaving={isSavingMeetingType}
integration={integration}
onCancel={() => setShowMeetingTypeForm(false)}
/>
)}
{meetingTypes.length === 0 ? (
<Card variant="glass" padding="lg" className={`text-center ${isAdmin ? 'mb-8' : ''}`}>
<Calendar className="w-12 h-12 mx-auto mb-4 text-ink-40" />
<Text muted>{isAdmin ? 'Noch keine Meeting-Typen erstellt. Erstelle deinen ersten Meeting-Typ für das Onboarding.' : 'Noch keine Meeting-Typen. Erstelle deinen ersten buchbaren Termin.'}</Text>
</Card>
) : (
<div className={`space-y-4 ${isAdmin ? 'mb-8' : ''}`}>
{meetingTypes.map((mt) =>
isAdmin ? (
<AdminMeetingTypeRow
key={mt.id}
mt={mt}
expanded={expandedMeetingTypeId === mt.id}
copied={copiedId === mt.id}
onToggleExpand={() => setExpandedMeetingTypeId(expandedMeetingTypeId === mt.id ? null : mt.id)}
onCopy={() => { setCopiedId(mt.id); setTimeout(() => setCopiedId(null), 2000); }}
locationLabel={locationLabel}
/>
) : (
<WorkspaceMeetingTypeRow
key={mt.id}
mt={mt}
copied={copiedId === mt.id}
onCopy={() => { setCopiedId(mt.id); setTimeout(() => setCopiedId(null), 2000); }}
locationLabel={locationLabel}
/>
)
)}
</div>
)}
{/* Bookings */}
{isAdmin ? (
<div>
<div className="mb-4"><Heading level={2}>Aktuelle Buchungen</Heading></div>
{bookings.length === 0 ? (
<Card variant="glass" padding="lg" className="text-center">
<Calendar className="w-12 h-12 mx-auto mb-4 text-ink-40" />
<Text muted>Noch keine Buchungen vorhanden</Text>
</Card>
) : (
<div className="space-y-3">
{bookings.map((booking) => (
<Card key={booking.id} variant="glass" padding="md">
<div className="flex items-center justify-between">
<div>
<Text weight="medium">{booking.attendee_name}</Text>
<Text muted size="sm">{booking.attendee_email}</Text>
</div>
<div className="text-right">
<Text size="sm">{formatDateTime(booking.starts_at)}</Text>
<Badge variant={booking.status === 'confirmed' ? 'success' : booking.status === 'cancelled' ? 'error' : 'secondary'} size="sm">{booking.status}</Badge>
</div>
{booking.google_event_link && (
<a href={booking.google_event_link} target="_blank" rel="noopener noreferrer" className="ml-2">
<ExternalLink className="w-4 h-4 text-ink-60 hover:text-[#6844C4]" />
</a>
)}
</div>
</Card>
))}
</div>
)}
</div>
) : (
<div className="mt-10">
<Heading level={2} className="mb-4">Gebuchte Termine</Heading>
{bookings.length > 0 && (today.length > 0 || upcoming.length > 0) && (
<Card variant="glass" padding="md" className="mb-6 bg-gradient-to-r from-[#6844C4]/5 to-transparent border-l-4 border-[#6844C4]">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-[#6844C4]/10 flex items-center justify-center">
<Calendar className="w-5 h-5 text-[#6844C4]" />
</div>
<div>
{today.length > 0 ? (
<>
<Text weight="semibold">{today.length === 1 ? 'Ein Termin heute' : `${today.length} Termine heute`}</Text>
<Text muted size="sm">Nächster: {formatTime(today[0].starts_at)} - {today[0].attendee_name}</Text>
</>
) : (
<>
<Text weight="semibold">Kein Termin heute</Text>
<Text muted size="sm">Nächster: {formatDateShort(upcoming[0].starts_at, now)} {formatTime(upcoming[0].starts_at)}</Text>
</>
)}
</div>
</div>
</Card>
)}
{bookings.length === 0 ? (
<Card variant="glass" padding="lg" className="text-center">
<Calendar className="w-12 h-12 mx-auto mb-4 text-ink-40" />
<Text muted>Noch keine Buchungen vorhanden.</Text>
</Card>
) : (
<div className="space-y-6">
{today.length > 0 && (
<div>
<Text weight="semibold" size="sm" className="mb-3 text-green-600 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Heute ({today.length})
</Text>
<div className="space-y-2">
{today.map((booking) => {
const mt = meetingTypes.find((m) => m.id === booking.meeting_type_id);
return (
<Card key={booking.id} variant="glass" padding="sm" className="border-l-4 border-green-500">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="text-center min-w-[50px]"><Text weight="semibold" size="lg">{formatTime(booking.starts_at)}</Text></div>
<div>
<Text weight="medium">{booking.attendee_name}</Text>
<Text muted size="sm">{booking.attendee_email}</Text>
{mt && <Text muted size="xs">{mt.name} - {mt.duration_minutes} Min.</Text>}
</div>
</div>
<div className="flex items-center gap-2">
{booking.meeting_link && <Button variant="primary" size="sm"><Video className="w-4 h-4 mr-1" />Meeting</Button>}
{booking.google_event_link && <Button variant="ghost" size="sm" title="In Google Calendar öffnen"><ExternalLink className="w-4 h-4" /></Button>}
</div>
</div>
</Card>
);
})}
</div>
</div>
)}
{upcoming.length > 0 && (
<div>
<Text weight="semibold" size="sm" className="mb-3 text-[#6844C4] flex items-center gap-2">
<Clock className="w-4 h-4" />
Bevorstehend ({upcoming.length})
</Text>
<div className="space-y-2">
{upcoming.map((booking) => {
const mt = meetingTypes.find((m) => m.id === booking.meeting_type_id);
return (
<Card key={booking.id} variant="glass" padding="sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="text-center min-w-[70px]">
<Text weight="semibold" size="sm">{formatDateShort(booking.starts_at, now)}</Text>
<Text muted size="xs">{formatTime(booking.starts_at)}</Text>
</div>
<div>
<Text weight="medium">{booking.attendee_name}</Text>
<Text muted size="sm">{booking.attendee_email}</Text>
{mt && <Text muted size="xs">{mt.name}</Text>}
</div>
</div>
{booking.google_event_link && <Button variant="ghost" size="sm" title="In Google Calendar öffnen"><ExternalLink className="w-4 h-4" /></Button>}
</div>
</Card>
);
})}
</div>
</div>
)}
{past.length > 0 && (
<div>
<button onClick={() => setShowPastBookings(!showPastBookings)} className="w-full flex items-center justify-between py-2 text-left">
<Text muted size="sm">Vergangene Termine ({past.length})</Text>
{showPastBookings ? <ChevronUp className="w-4 h-4 text-ink-60" /> : <ChevronDown className="w-4 h-4 text-ink-60" />}
</button>
{showPastBookings && (
<div className="space-y-2 mt-2">
{past.slice(0, 10).map((booking) => {
const mt = meetingTypes.find((m) => m.id === booking.meeting_type_id);
return (
<Card key={booking.id} variant="glass" padding="sm" className="opacity-60">
<div className="flex items-center gap-3">
<div className="text-center min-w-[70px]">
<Text muted size="sm">{formatDateShort(booking.starts_at, now)}</Text>
<Text muted size="xs">{formatTime(booking.starts_at)}</Text>
</div>
<div>
<Text muted weight="medium">{booking.attendee_name}</Text>
<Text muted size="sm">{booking.attendee_email}</Text>
{mt && <Text muted size="xs">{mt.name}</Text>}
</div>
</div>
</Card>
);
})}
{past.length > 10 && <Text muted size="sm" className="text-center py-2">+ {past.length - 10} weitere vergangene Termine</Text>}
</div>
)}
</div>
)}
{today.length === 0 && upcoming.length === 0 && past.length === 0 && (
<Card variant="glass" padding="lg" className="text-center"><Text muted>Keine Termine gefunden.</Text></Card>
)}
</div>
)}
</div>
)}
</>
)}
</div>
);
}
function MessageStepsSummary({ steps }: { steps: MessageStep[] }) {
if (steps.length === 0) {
return <Text size="xs" muted>Eingeschaltet, aber noch keine Nachricht geschrieben.</Text>;
}
return (
<div className="space-y-2">
{steps.map((step) => (
<div key={step.id} className="flex items-center gap-2">
<Badge variant="secondary" size="sm">{MESSAGE_TYPE_LABEL[step.type]}</Badge>
<Text size="xs" muted>{formatTiming(step)}</Text>
</div>
))}
<Text size="xs" muted className="pt-1">Der Editor (MessageStepsEditor, forms-Domäne) ist nicht Teil dieses Kits.</Text>
</div>
);
}
function WorkspaceMeetingTypeRow({ mt, copied, onCopy, locationLabel }: { mt: MeetingType; copied: boolean; onCopy: () => void; locationLabel: (v: string) => string | undefined }) {
const embed = `<iframe src="https://api.example.com/api/calendar/embed/${mt.slug}" width="100%" height="600" frameborder="0"></iframe>`;
return (
<Card variant="glass" padding="md">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-3 h-12 rounded-full" style={{ backgroundColor: mt.color }} />
<div>
<Text weight="semibold">{mt.name}</Text>
<Text muted size="sm" className="flex items-center gap-2 flex-wrap">
<Clock className="w-3 h-3" />
{mt.duration_minutes} Min.
<span className="mx-1">•</span>
{locationLabel(mt.location_type)}
<span className="mx-1">•</span>
Vorlauf {mt.min_notice_hours}h
<span className="mx-1">•</span>
Puffer {mt.buffer_before_minutes}/{mt.buffer_after_minutes} Min.
</Text>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onCopy} title="Embed-Code kopieren">
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Code className="w-4 h-4" />}
</Button>
<Button variant="ghost" size="sm"><Edit className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm"><Trash2 className="w-4 h-4 text-red-500" /></Button>
</div>
</div>
<div className="mt-3 p-3 bg-stone-100 rounded-lg">
<Text muted size="xs" className="mb-1 flex items-center gap-1"><Code className="w-3 h-3" />Embed-Code</Text>
<code className="text-xs break-all">{embed}</code>
</div>
</Card>
);
}
function AdminMeetingTypeRow({ mt, expanded, copied, onToggleExpand, onCopy, locationLabel }: { mt: MeetingType; expanded: boolean; copied: boolean; onToggleExpand: () => void; onCopy: () => void; locationLabel: (v: string) => string | undefined }) {
const embed = `<iframe src="https://api.example.com/api/calendar/embed/${mt.slug}" width="100%" height="600" frameborder="0"></iframe>`;
return (
<Card variant="glass" padding="md">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-3 h-12 rounded-full" style={{ backgroundColor: mt.color }} />
<div>
<div className="flex items-center gap-2">
<Text weight="semibold">{mt.name}</Text>
{!mt.is_active && <Badge variant="secondary" size="sm">Inaktiv</Badge>}
</div>
<Text muted size="sm" className="flex items-center gap-2">
<Clock className="w-3 h-3" />
{mt.duration_minutes} Min.
<span className="mx-1">•</span>
{locationLabel(mt.location_type)}
<span className="mx-1">•</span>
/{mt.slug}
</Text>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onCopy} title="Embed-Code kopieren">
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
<Button variant="ghost" size="sm"><Edit className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm"><Trash2 className="w-4 h-4 text-red-500" /></Button>
<Button variant="ghost" size="sm" onClick={onToggleExpand}>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</Button>
</div>
</div>
{expanded && (
<div className="mt-4 pt-4 border-t border-line">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div><Text muted size="xs">Mindestvorlauf</Text><Text>{mt.min_notice_hours}h</Text></div>
<div><Text muted size="xs">Max. Tage voraus</Text><Text>{mt.max_days_ahead}</Text></div>
<div><Text muted size="xs">Puffer vorher</Text><Text>{mt.buffer_before_minutes} Min.</Text></div>
<div><Text muted size="xs">Puffer nachher</Text><Text>{mt.buffer_after_minutes} Min.</Text></div>
</div>
{mt.description && <div className="mt-3"><Text muted size="xs">Beschreibung</Text><Text size="sm">{mt.description}</Text></div>}
<div className="mt-4 p-3 bg-stone-100 rounded-lg">
<Text muted size="xs" className="mb-2">Embed-Code</Text>
<code className="text-xs break-all">{embed}</code>
</div>
</div>
)}
</Card>
);
}
/**
* The workspace form (CalendarSettings.tsx 758-1086). Genuine, reproduced gap (Data model / Gotchas): only the
* `zoom_link` field is conditionally rendered for its location type (843-857) — `custom_location` (in_person) and
* `phone_number` (phone) have no input anywhere in this form, unlike the admin file's form (which has both, not
* rendered here since no state opens it, see the header comment).
*/
function MeetingTypeForm({ form, setForm, editing, isSaving, integration, onCancel }: {
form: MeetingTypeFormState; setForm: (f: MeetingTypeFormState) => void; editing: boolean; isSaving: boolean; integration: CalendarIntegration; onCancel: () => void;
}) {
return (
<Card variant="glass" padding="lg" className="mb-6">
<Heading level={3} className="mb-4">{editing ? 'Meeting-Typ bearbeiten' : 'Neuer Meeting-Typ'}</Heading>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Text as="label" size="sm" weight="medium" className="block mb-2">Name</Text>
<Input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, slug: editing ? form.slug : generateSlug(e.target.value) })} placeholder="z.B. Discovery Call" />
</div>
<div>
<Text as="label" size="sm" weight="medium" className="block mb-2">Slug (URL)</Text>
<Input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} placeholder="discovery-call" disabled={editing} />
</div>
<div className="md:col-span-2">
<Text as="label" size="sm" weight="medium" className="block mb-2">Beschreibung</Text>
<Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optionale Beschreibung" rows={2} />
</div>
<div>
<Text as="label" size="sm" weight="medium" className="block mb-2">Dauer (Minuten)</Text>
<Input type="number" value={form.duration_minutes} onChange={(e) => setForm({ ...form, duration_minutes: parseInt(e.target.value) || 60 })} min={15} max={480} />
</div>
<div>
<Text as="label" size="sm" weight="medium" className="block mb-2">Meeting-Ort</Text>
<select value={form.location_type} onChange={(e) => setForm({ ...form, location_type: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-line bg-white">
{LOCATION_TYPES_WORKSPACE.map((lt) => <option key={lt.value} value={lt.value}>{lt.label}</option>)}
</select>
</div>
{form.location_type === 'zoom' && (
<div className="md:col-span-2">
<Text as="label" size="sm" weight="medium" className="block mb-2">Zoom Link</Text>
<Input type="url" value={form.zoom_link} onChange={(e) => setForm({ ...form, zoom_link: e.target.value })} placeholder="https://zoom.us/j/..." />
</div>
)}
<div>
<Text as="label" size="sm" weight="medium" className="block mb-2">Farbe</Text>
<div className="flex items-center gap-2">
<input type="color" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} className="w-10 h-10 rounded cursor-pointer" />
<Input type="text" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} className="flex-1" />
</div>
</div>
<div>
<Text as="label" size="sm" weight="medium" className="block mb-2">Mindestvorlauf (Stunden)</Text>
<Input type="number" value={form.min_notice_hours} onChange={(e) => setForm({ ...form, min_notice_hours: parseInt(e.target.value) || 24 })} min={0} max={168} />
</div>
<div>
<Text as="label" size="sm" weight="medium" className="block mb-2">Max. Tage im Voraus</Text>
<Input type="number" value={form.max_days_ahead} onChange={(e) => setForm({ ...form, max_days_ahead: parseInt(e.target.value) || 28 })} min={1} max={365} />
</div>
<div>
<Text as="label" size="sm" weight="medium" className="block mb-2">Puffer vorher (Min.)</Text>
<Input type="number" value={form.buffer_before_minutes} onChange={(e) => setForm({ ...form, buffer_before_minutes: parseInt(e.target.value) || 0 })} min={0} max={120} />
</div>
<div>
<Text as="label" size="sm" weight="medium" className="block mb-2">Puffer nachher (Min.)</Text>
<Input type="number" value={form.buffer_after_minutes} onChange={(e) => setForm({ ...form, buffer_after_minutes: parseInt(e.target.value) || 15 })} min={0} max={120} />
</div>
</div>
<div className="mt-6">
<Text as="label" size="sm" weight="medium" className="block mb-2">Zeitzone</Text>
<select value={integration.timezone} disabled className="w-full rounded-lg border border-line bg-white px-3 py-2 text-sm">
<option>{integration.timezone}</option>
</select>
<Text size="xs" muted className="mt-1">Alle Verfügbarkeitszeiten beziehen sich auf diese Zeitzone.</Text>
</div>
<div className="mt-6">
<Text as="label" size="sm" weight="medium" className="block mb-3">Verfügbarkeit ({integration.timezone})</Text>
<div className="space-y-2">
{Object.entries(DAY_LABELS).map(([day, label]) => (
<div key={day} className="flex items-center gap-3">
<Text className="w-24 shrink-0">{label}</Text>
<Input
type="text"
value={(form.availability_schedule[day] || []).join(', ')}
onChange={(e) => setForm({ ...form, availability_schedule: { ...form.availability_schedule, [day]: e.target.value.split(',').map((t) => t.trim()).filter(Boolean) } })}
placeholder="z.B. 09:00-12:00, 14:00-17:00"
className="flex-1"
/>
</div>
))}
<Text size="xs" muted className="mt-1">Format: HH:MM-HH:MM. Mehrere Zeitfenster mit Komma trennen. Leer lassen = nicht verfügbar.</Text>
</div>
</div>
<div className="mt-6 pt-6 border-t border-line">
<div className="flex items-start justify-between gap-4 mb-3">
<div>
<Text as="label" size="sm" weight="medium" className="block">Nachrichten</Text>
<Text size="xs" muted className="mt-0.5">Bestätigung, Erinnerung und Follow-up per E-Mail. Platzhalter: <code>{'{{name}}'}</code>, <code>{'{{meeting}}'}</code>, <code>{'{{date}}'}</code>, <code>{'{{time}}'}</code>, <code>{'{{location}}'}</code>.</Text>
</div>
<Toggle checked={form.messages !== null} onChange={(on: boolean) => setForm({ ...form, messages: on ? [] : null })} />
</div>
{form.messages === null ? (
<Text size="xs" muted>Verwendet die Standard-Nachrichten des Kalenders. Einschalten, um für diesen Meeting-Typ eigene zu schreiben.</Text>
) : (
<MessageStepsSummary steps={form.messages} />
)}
</div>
<div className="mt-6 flex items-center gap-3">
<Button variant="primary" disabled={isSaving || !form.name || !form.slug}>
{editing ? 'Speichern' : 'Erstellen'}
</Button>
<Button variant="ghost" onClick={onCancel}>Abbrechen</Button>
</div>
</Card>
);
}
export default function Demo({ viewer: pageViewer, workspace: ws, galleryMode, ...page }: Props & { galleryMode?: boolean }) {
const activePath = page.scope === 'platform-admin' ? '/admin/calendar' : '/calendar';
return (
<Shell domains={DOMAINS} user={pageViewer} workspaces={[ws]} currentWorkspaceId={ws.id} activePath={activePath} hasTour={false} hideChrome={galleryMode}>
<CalendarPanel {...page} />
</Shell>
);
}
Component tree, from the recipe
<Route /calendar> PrivateRoute (children) ("Bookings" domain, nav.tsx line 133)
<Route /admin/calendar> AdminRoute (children) ("Operate" domain in the real app's own nav.tsx;
the kit's shared appshell-navigation/nav.tsx
does not list this path yet, Gotchas)
Navigation | AdminNavigation = AppShell (thin delegators)
CalendarSettings | AdminCalendarSettings
[loading] div.h-screen flex-center > Spinner xl "Laden..." (546-554 | 366-373)
[no workspace] Card: "Workspace einrichten" + create button (workspace scope only, 512-544,
out of this recipe's scope,
Gotchas)
Header Heading "Kalender" | "Kalender-Integration" + Text + [integration] "Aktualisieren"
[message] Card glass: green | red banner, plain Text, no icon
Connection status Card glass lg
icon chip (Link2 green | Link2Off stone) + "verbunden"/"nicht verbunden" + account line
[connected] Button outline "Trennen" | [else] Button primary "Verbinden"
[workspace, !google] AlternativeCalendarConnect (header card reproduced, its open tab forms not
part of this recipe's packages, Frontend)
[workspace, integration] Standard-Nachrichten Card
Heading + placeholder-copy Text + Toggle
[off] "Keine Standard-Nachrichten..." | [on] per-step summary (MessageStepsEditor
itself not part of this recipe's packages)
[integration] Meeting-Typen
Heading + Button primary "Neu" | "Neuer Meeting-Typ" (PrimaMachtsButton, workspace
only, not rendered, Frontend)
[workspace, showForm] Meeting type form (name/slug/description/duration/location/
[zoom]link/color/notice/lookahead/buffers/
timezone select/availability/Nachrichten
toggle, Data model's form gap)
[meetingTypes.length===0] empty Card
[else] row per type:
workspace: color bar + name + duration/location/notice/buffer line +
[copy|edit|delete] + embed code always visible below
admin: color bar + name + [Inaktiv badge] + duration/location/slug line +
[copy|edit|delete|expand] + details grid + embed only when expanded
Bookings
workspace: "Gebuchte Termine" heading, [today||upcoming] summary banner, empty Card,
Heute (pulse dot, video/google-link buttons) / Bevorstehend (google-link) /
Vergangene (collapsed, sliced 10, "+N weitere")
admin: "Aktuelle Buchungen" heading, empty Card, flat list with status Badge and
google-link anchor, no categorisation The rendering is the live rebuild calendar-settings-panel/demo.tsx, built from @prima-modules/alchemy-ui
and @prima-modules/tokens, rendered by the docs host as one frame per state; the tree is the frontmatter's.
The console is rendered inside the shared shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx),
the same convention identity-auth/ui/admin-users-console and billing-credits/ui/subscription-manager use, with
the "Bookings" domain active on /calendar for the five workspace states and, for admin-variant, viewer. isAdmin: true on /admin/calendar - a path the kit's own shared nav.tsx does not yet list under its
"Operate" domain, unlike the real app's nav.tsx (Gotchas), so the shell's active-domain highlight is
approximate for that one state, a limitation of the shared shell recipe, not of this one.
States (rendering.states, in order): loading (the bare spinner, no cards at all, before either file knows
whether a calendar is connected); disconnected (no integration, the "nicht verbunden" card, the closed
iCal/CalDAV header card below it, workspace scope only - admin has no equivalent alternative-connect surface
so this state is not meaningfully different there); connected-empty (Google connected, zero meeting types and
zero bookings, both empty-state cards, Standard-Nachrichten off); populated (three meeting types spanning all
three location types, six active bookings split across today/upcoming/eleven-deep past - enough to show the
collapsed list's "+N weitere" overflow line - one cancelled booking present in the data and absent from every
bucket, Standard-Nachrichten on with two configured steps); meeting-type-form-open (the create form open over
the existing list, name/slug pre-typed, per-type Nachrichten left inheriting the default, the real, reproduced
gap that this form never renders a custom_location or phone_number input for any location type - only
zoom_link is conditionally shown, Data model); admin-variant (the platform-admin approximation: no
Standard-Nachrichten section, no alternative-connect card, "Neuer Meeting-Typ" instead of "Neu", one row shown
expanded to prove the chevron-detail branch, an "Inaktiv" badge on the second row, a flat "Aktuelle Buchungen"
list with a raw status Badge and one cancelled row visible - the admin file has no categorisation to hide
it, unlike the workspace file's filter).
What the demo deliberately does not render, all documented in the demo file's own header comment: the OAuth
popup and its postMessage handshake; window.confirm before disconnect/delete; AlternativeCalendarConnect's
open iCal/CalDAV tab forms (network-reaching on mount, not part of the four packages a demo may import from);
PrimaMachtsButton (needs PrimaContext); MessageStepsEditor itself, replaced by a read-only per-step
summary line; the admin file's own meeting-type FORM (address/phone fields it has and the workspace form
lacks, a static timezone line instead of a selector, lower buffer/lookahead caps) - no state opens it, so it is
named here and in Reference implementation rather than built unused; navigator.clipboard.writeText's actual
call (the copied-checkmark swap is reproduced from state alone).
Tokens and classes as the source uses them: Card glass for every top-level section, plain (no variant)
for the Standard-Nachrichten card; the connection icon chip bg-green-100/text-green-600 connected,
bg-stone-100/text-ink-60 not; today's booking rows border-l-4 border-green-500 with a bg-green-500 animate-pulse dot; the brand purple #6844C4 used as a literal hex throughout (not an S1 token) for the
header icon, active-tier accents and the summary banner's gradient border, the same "literal hex, not a token"
pattern billing-credits/ui/subscription-manager's amber palette already flags. dark: variants are absent
from both source files entirely (unlike the billing and admin-console pages), so there is no "dark: dead" trap
to reproduce here - a genuine, measured difference from this domain's siblings, worth noting precisely because
it is not the pattern one would expect from the rest of Labs.
Responsive: both files are a single max-w-4xl (workspace) / max-w-6xl (admin) column; the meeting-type
form's field grid collapses from two columns to one below md; the bookings/meeting-type rows stay single-
column throughout, nothing here builds a DataTable. Empty state: connected-empty for meeting types and
bookings together; disconnected for the calendar itself. Loading state: the bare spinner on first mount only;
none of the eight writes shows a loading indicator anywhere but its own triggering button (isConnecting,
isSavingMeetingType, isSavingDefaults each gate exactly one button).
Security & tenancy
What the browser may hold: S2's two tokens only, never a calendar credential - OAuth happens server-side, the
popup only carries Google's own consent screen. import.meta.env.VITE_API_URL is the only baked value (also
read directly by calendarEmbed.ts for the embed <iframe src>, outside the api singleton). Origin and
CSRF: the api singleton's Bearer header, S2's own; the OAuth-callback listener explicitly checks
isTrustedMessageOrigin(event.origin) before trusting any postMessage, the one place on this page that
takes browser-side input from something other than a form field or the server. Surface gating: all workspace
and admin calendar routes sit under LABS_SURFACES (app_fastapi.py), the same B4 gate the billing routes
sit behind; the admin routes are additionally behind AdminRoute client-side and an admin dependency
server-side (B16), so a non-admin hitting /admin/calendar directly is refused before any handler runs.
Tenancy: the workspace routes derive their scope from workspace_id (query param, checked against the
caller's membership, calendar_router.py 1025 and siblings - a 403 on mismatch); the admin routes have no
workspace concept at all, one platform-level integration (is_platform_level: true) shared across the whole
platform admin console, the same "not workspace-scoped inside a workspace-scoped shell" shape billing-credits/
ui/subscription-manager's own Security section names for its per-user balance. Attacker-controlled input the
page sends: the meeting-type form's every field, re-validated server-side (slug uniqueness, duration/buffer/
lookahead ranges); the OAuth redirect_url is window.location.href, sent to the backend and echoed back by
Google, not rendered unescaped anywhere on this page. What the page renders from the server unescaped: nothing
directly-dangerous; React escapes every string, no dangerouslySetInnerHTML; the embed <iframe> shown to the
operator is a <code> text block, not a live iframe (this page never actually mounts the iframe it hands out).
Tests
- Colocated with the demo:
calendar-settings-panel/demo.test.tsx(vitest, jsdom,@testing-library/react,afterEach(cleanup)): each state's meeting-type row count matches itsmeetingTypes.length;today/upcoming/pastbooking counts inpopulatedmatchcategorizeBookings(bookingsSample, NOW), independent of the component; the cancelled sample booking (bk_cancelled) renders in none of the three buckets; the Standard-NachrichtenToggleflips the on/off copy inconnected-empty(off) versuspopulated(on);admin-variantrenders no Standard-Nachrichten card and noAlternativeCalendarConnectheader at all (queryByTextreturns null); the workspacemeeting-type-form-openstate renders azoom_linkinput whenlocation_typeis'zoom'and nocustom_location/phone_numberinput for any location type (the reproduced gap, Data model); no state's markup contains an access- or refresh-token shape. - The kit's render smoke:
node tooling/verify/render-smoke.mjs calendar-booking/ui/calendar-settings-panelrenders all six states withrenderToString,fetchstubbed - run and green before commit (Acceptance checks). - Labs at 62ba4fc has no unit test of either
CalendarSettings.tsxorAdminCalendarSettings.tsx(grep -rl "CalendarSettings\|AdminCalendarSettings" frontend/src --include='*.test.*'is empty) and no Playwright spec for either route. Backend coverage of the routes both files call belongs to the fivebackend_modulesrecipes' own suites, not to this page. - What a new app adds: a jsdom test with
apimocked asserting the six catch blocks readdata.error ?? data.detail(once fixed) instead ofdata.detailalone; a test that the timezone patch's silent failure (no message card at all, API surface) now shows one; a Playwright spec that connects a real (sandboxed) Google account, creates a meeting type, and asserts its embed code renders a working public booking page - the gap this recipe's Gotchas names for the two files' complete lack of coverage today.
Deploy & ops
Build target: the Labs Vite bundle (S2), two separate lazy chunks (App.tsx lines 73 and 134,
lazy(() => import('./pages/CalendarSettings')) and .../AdminCalendarSettings) - the two files are never
code-split together, so a rebuild that unified them into one component with a scope prop (as this recipe's
demo does, for documentation purposes only) would change Labs' own bundle-splitting, a real deviation named
here rather than silently assumed away. Env baked at build: VITE_API_URL, S2's own, also read directly by
calendarEmbed.ts. CSP and CORS: nothing of its own beyond the SPA's existing allowance. Deploy path: S13's
frontend rsync with the bundle. Nothing to migrate here directly; the schema both files read and write
(calendar_integrations, meeting_types, bookings and siblings) belongs to the five backend_modules
recipes. Argus: a synthetic session calling GET /workspace/integration (or /admin/integration) returning
200 is this page's liveness; neither file has a health signal of its own beyond its fixed fallback sentences.
Gotchas
- 2026-09-12 (measured at 62ba4fc): both files' six write-path catch blocks read
error.response?.data?. detail(workspace: lines 268, 288, 373, 392, 716; admin: lines 222, 244, 305, 337) against an envelope whose key iserror(error_schemas.pycreate_error_response, line 130), so every specific server message is discarded and replaced by one of six fixed fallback sentences - the same class of bug billing-credits/ui/ subscription-manager and identity-auth/ui/admin-users-console each document independently for their own pages; this is now measured on a third, unrelated router in the same codebase. - 2026-09-12 (measured at 62ba4fc): the workspace file's timezone patch (
onChangeon the integration timezone<select>, lines 959-969) has no user-facing failure path at all - its catch onlyconsole.errors. A patch that 403s or 400s (an invalid IANA name) leaves the<select>silently reverted with zero on-screen explanation, worse than the six sentence-fallback writes above, which at least say something wrong happened. - 2026-09-12 (measured at 62ba4fc): the workspace meeting-type form conditionally renders an input for exactly
one of its three location-dependent fields -
zoom_link(lines 843-857) - nevercustom_location(forin_person) orphone_number(forphone), even thoughMeetingTypeand the save payload both carry all three and the admin file's own form (lines 574-611 at 62ba4fc, not rendered in this recipe's demo, Frontend) does render both. An operator picking "Vor Ort" or "Telefon" workspace-side has no way to type the address or number into this form at all; the field can only ever be set by whatever created the row before, or via the admin console's own form on a platform-level integration. - 2026-09-12 (measured at 62ba4fc):
categorizedBookings(workspace 453-483) readsnew Date()live inside auseMemokeyed only onbookings; a tab left open across midnight keeps showing yesterday's "Heute" bucket until something else re-renders the page (no interval, no visibility-change listener). The demo replaces this with an injectednowprop for a different reason (deterministic server/client rendering, Frontend) that happens to also route around this trap - reproducing it exactly would need a live clock that arenderToStringcall cannot meaningfully have. - 2026-09-12 (measured at 62ba4fc): two lucide imports are dead in the workspace file -
User(line 22, never referenced) andCopy(line 15; the copy-embed button usesCode/Check, notCopy-Copyis what the admin file uses for the same button). The same class of harmless dead import billing-credits/ui/ subscription-manager's Gotchas names forCardContent. - 2026-09-12 (measured at 62ba4fc): the two files'
LOCATION_TYPESconstants list the same fourvalues but label two of them differently - "Telefon"/"Vor Ort" (workspace, lines 98-99) versus "Phone Call"/"In Person" (admin, lines 91-92) - the one place either file breaks from otherwise-consistent German copy, and it breaks silently: the storedlocation_typevalue is identical, only its rendered label differs by which file drew it. - 2026-09-12 (measured at 62ba4fc): the kit's own shared
ui-design-system/ui/appshell-navigation/nav.tsxdoes not list/admin/calendarunder its "Operate" domain'spaths(unlike the real app'snav.tsx, which has both the path, line 265, and a "Calendar (admin)" rail item, line 280, at the same commit) - so this recipe'sadmin-variantstate renders with the shell's active-domain highlight falling back to Home rather than Operate. Not a bug in this recipe or in Labs; a staleness in a shared demo file that belongs to a different recipe (ui-design-system/ui/appshell-navigation) and was left unfixed here rather than edited out of turn. - 2026-09-12 (kit): the domain directory was created directly as
modules/calendar-booking/ui/...(no doubledcalendar-booking/ui/calendar-booking/ui/...segment) from the start, having read billing-credits/ui/ subscription-manager's own Gotchas entry about exactly that collision first;npm run check'sinDemoFoldertest andrender-smoke.mjs's id lookup both pass on the first try (Acceptance checks).
Reference implementation
Branch: alchemy_labs main at 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12). Paths relative to the
repo root.
Workspace file, frontend/src/pages/CalendarSettings.tsx (1369 lines): imports and types (1-93), constants
(95-120), component state (122-171), fetchData (174-212), the OAuth-callback listener (219-235), handle Connect (238-272), handleDisconnect (275-291), the meeting-type form handlers (294-395), copyEmbedCode
(398-402), generateSlug (405-411), date formatters (414-450), categorizedBookings (453-483), the workspace
guards (498-554), googleConnected (559), header and message card (564-604), connection-status card
(607-657), AlternativeCalendarConnect mount (661-666), Standard-Nachrichten card (668-734), meeting-types
header (737-755), the meeting-type form (758-1086, the zoom-only conditional at 843-857), the meeting-types
list (1089-1163, embed preview 1151-1159), the bookings section (1166-1361).
Admin file, frontend/src/pages/AdminCalendarSettings.tsx (969 lines): types (37-79) - no messages field on
MeetingType; component state (116-149) - no defaultMessages/defaultsLoaded/isSavingDefaults; fetchData
(151-177, admin-only endpoints, bookings capped at limit: 10 server-side); OAuth listener (180-198);
handleConnect (201-231, English error strings at lines 217, 226); handleDisconnect (232-248, also clears
bookings); meeting-type handlers (249-339); copyEmbedCode (340-345, no integrationId argument); loading
guard (367-375, no workspace guard at all); header and connection card (376-478); meeting-types header
(479-496, "Neuer Meeting-Typ"); the meeting-type form (497-777, custom_location and phone_number inputs at
548-561 and 587-600 respectively, no timezone selector, static tz text at 651-659, buffer caps of 60 and
max_days_ahead capped at 90 versus the workspace form's 120/365); the meeting-types list (778-911, the
expand/collapse chevron at 856-870, the "Inaktiv" badge at 809-813); the flat bookings list (912-967).
Supporting: frontend/src/components/calendar/AlternativeCalendarConnect.tsx (header row reproduced, tab forms
not); frontend/src/components/prima/PrimaMachtsButton.tsx (not rendered); frontend/src/components/forms/ TallyFormBuilder/MessageStepsEditor.tsx (not mounted, summarised); frontend/src/utils/calendarEmbed.ts
(generateEmbedCode/getEmbedUrl, VITE_API_URL); frontend/src/utils/messageOrigin.ts
(isTrustedMessageOrigin); frontend/src/contexts/WorkspaceContext.tsx (the workspace guards' source, not
this recipe's territory); frontend/src/components/nav.tsx (real "Bookings" domain 127-136, real "Operate"
domain including /admin/calendar 256-281); frontend/src/App.tsx (both lazy imports and both routes: 73,
134, 455-459, 799-806); backend/api/routers/calendar_router.py (every route in API surface); backend/models/ calendar_integration_model.py (is_platform_level); backend/app_fastapi.py 719-767 (the envelope both files
misread), backend/schemas/error_schemas.py 112-136 (create_error_response, the error key).
Deviations of the reference from this recipe, all dated 2026-09-12: the detail reads on both files; the
timezone patch's total silence on failure; the workspace form's missing custom_location/phone_number
inputs; the live new Date() in categorizedBookings; the two dead workspace imports; the German/English
LOCATION_TYPES label mismatch; and, specific to this recipe's admin-variant state, the shared shell demo's
nav.tsx not yet listing /admin/calendar the way the real app's own nav.tsx does.
Acceptance checks
From alchemy_labs/frontend at 62ba4fc with the harness up (tooling/verify/labs-local.sh up, an owner
session for /calendar, an admin session for /admin/calendar), or from a new app built to this recipe:
grep -c "data?.detail" src/pages/CalendarSettings.tsx src/pages/AdminCalendarSettings.tsxprints5and3respectively at 62ba4fc (workspace: connect/disconnect/save/delete/default-messages; admin: connect/ disconnect/save - admin has no delete- or default-messages-specific catch reading.detailbeyond what is already counted) and0/0after the fix.grep -n "location_type === 'in_person'\|location_type === 'phone'" src/pages/CalendarSettings.tsxprints nothing at 62ba4fc (the reproduced gap) and at least one line per branch once fixed.- Demo gate:
node tooling/verify/render-smoke.mjs calendar-booking/ui/calendar-settings-panelrenders all six states withrenderToString,fetchstubbed, 0 failures (run below, result in this task's report). - Browser walk-through, signed in as a workspace member with Google connected and at least one meeting type:
(a) open
/calendar, observe the shell paint first, then the spinner, then the connection card; (b) create a meeting type with "Vor Ort" selected, observe no address field anywhere in the form (the reproduced gap); (c) toggle Standard-Nachrichten on, observe the placeholder copy change, save, reload, observe it stayed on; (d) as a platform admin, open/admin/calendar, observe no Standard-Nachrichten section exists there at all and the rail does not highlight "Operate" (the shared nav.tsx gap, Gotchas). - Verifier:
node tooling/verify/compare.mjs calendar-booking/ui/calendar-settings-panel --state populated --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/ session.json(an owner session with Google connected, three meeting types and bookings seeded around the run's actual "today") writes a report withpage_errors: [];mismatch_ratiois read for the header and the connection-status card only, since the app's real booking dates and the demo's fixed sample dates will not agree on which bucket "today" falls into once real time has moved past 2026-09-12.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.