First Class Modules
Calendar
Provides secured month/agenda scheduling for events, attendees, resources, reminders, locations, private events, custom fields, relationships, activity, and favorites.
module.calendarUse Calendar for meetings, appointments, deadlines, visits, resource bookings, and other time-based work that must remain connected to the app's records and permissions.
The bundled renderer key is module.calendar. First-class means BuildWithHQ supplies a native, typed, secured runtime experience inside normal app provisioning and the signed-in user's existing permissions.
What it does
Provides secured month/agenda scheduling for events, attendees, resources, reminders, locations, private events, custom fields, relationships, activity, and favorites.
Key capabilities
- Browse monthly event ranges and search visible events.
- Create timed or all-day events with location, reminder, color, and privacy settings.
- Show attendee responses, resource bookings, custom fields, related records, and activity.
- Favorite important events and connect them to customers, projects, work orders, or cases.
Common uses
- Customer meetings and project milestones.
- Technician visits, inspections, shifts, or resource schedules.
- Renewal dates, campaign launches, approval deadlines, and case hearings.
How it connects
Events can relate to Contacts, Work Orders, Conversations, Files, and any authorized dynamic record. Resource bookings remain separate records so shared equipment, rooms, or people can be scheduled without embedding availability in page content.
Where applicable, its records use the universal RecordId conventions so they can participate in secured relationships, activity history, favorites, dynamic fields, notifications, Inbox attention, and global search without copying the source record.
Security and data boundary
Calendar queries enforce tenant, DataRole, location, and private-event rules. Attendees and related records are independently secured, and event creation/editing requires the corresponding server permission.
- The authenticated service derives the SaaS app, tenant account, user, DataRole, and location scope; browser identifiers are never authorization proof.
- The page editor composes React components with validated data bindings. Those bindings call typed runtime APIs, whose application services execute reviewed stored procedures.
- List, search, detail, relation, activity, favorite, and write operations reapply their required server-side permissions.
Add it to an app
- Open the SaaS app in the Developer Console and identify the user journey and page where this module belongs.
- Add the validated
module.calendarmodule block through the supported page/template authoring flow. - Configure the module with the page editor's React components and validated data bindings; the bindings call authenticated platform APIs backed by reviewed stored procedures.
- Place the page in the correct user-type menus and assign existing DataRole, record, field, and location permissions.
- Test list, detail, search, empty, denied, stale-update, and cross-location behavior before publishing an exact version.
Copy/paste the Calendar page
The native block supplies a secured month agenda, search, event creation, detail, attendees, resource/relation counts, custom fields, private-event handling, and favorites:
{
"blocks": [
{ "_id": "team-calendar", "_type": "module.calendar", "props": {}, "children": [] }
]
}
Custom unstyled React calendar
The list route requires an explicit UTC window no wider than 366 days and returns one authorized page of at most 500 events. Location and owner identifiers are filters, not authorization proof.
import { FormEvent, useEffect, useMemo, useState } from "react";
import type { CalendarEventDetail, CalendarEventList } from "@buildwithhq/module-sdk";
import { getCalendarEvent, listCalendarEvents } from "./api";
const empty: CalendarEventList = { contractVersion: 1, windowStart: new Date(0).toISOString(), windowEnd: new Date(1).toISOString(), pageNumber: 1, pageSize: 100, totalRecords: 0, totalPages: 0, items: [] };
export function UnstyledCalendar({ locationId = "", ownerUserId = "" }) {
const window = useMemo(() => {
const now = new Date();
return {
start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString(),
end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString(),
};
}, []);
const [draft, setDraft] = useState("");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [result, setResult] = useState<CalendarEventList>(empty);
const [selected, setSelected] = useState<CalendarEventDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
listCalendarEvents(window.start, window.end, search, controller.signal, locationId, ownerUserId, page, 100)
.then(setResult)
.catch((caught: unknown) => { if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "Calendar could not be loaded."); });
return () => controller.abort();
}, [locationId, ownerUserId, page, search, window]);
function submit(event: FormEvent) { event.preventDefault(); setPage(1); setSearch(draft.trim()); }
async function open(recordId: string) { setSelected(await getCalendarEvent(recordId)); }
return <section className="custom-calendar">
<h1>Calendar</h1>
<form role="search" onSubmit={submit}><label>Search events<input value={draft} onChange={e => setDraft(e.target.value)} /></label><button>Search</button></form>
{error && <p role="alert">{error}</p>}
<ol>{result.items.map(item => <li key={item.recordId}><button onClick={() => void open(item.recordId)}>
<time dateTime={item.startUtc}>{new Date(item.startUtc).toLocaleString()}</time><strong>{item.title}</strong><span>{item.locationText || item.locationName || "No location"}{item.isPrivate ? " - Private" : ""}</span>
</button></li>)}</ol>
<nav aria-label="Calendar pages"><button disabled={page <= 1} onClick={() => setPage(value => value - 1)}>Previous</button><span>Page {result.pageNumber} of {Math.max(1, result.totalPages)}</span><button disabled={page >= result.totalPages} onClick={() => setPage(value => value + 1)}>Next</button></nav>
{selected && <article><h2>{selected.title}</h2><p>{selected.description}</p><p>{selected.attendeeCount} attendees - {selected.resourceBookingCount} resources - {selected.relationCount} related records</p><ul>{selected.attendees.map(person => <li key={person.eventAttendeeId}>{person.displayName || person.externalEmail} - {person.responseStatus}</li>)}</ul></article>}
</section>;
}
Create, replace, and favorite events
Update is a full optimistic replacement. Reconstruct it from current detail and keep its latest updatedUtc. The server revalidates location, owner, private-event, dynamic-field, and edit scope.
import type { CalendarDynamicField, CalendarEventDetail, CalendarEventWrite } from "@buildwithhq/module-sdk";
import { createCalendarEvent, getCalendarEvent, setCalendarFavorite, updateCalendarEvent } from "./api";
const fieldInput = (field: CalendarDynamicField) => ({
fieldKey: field.fieldKey, valueText: field.valueText ?? undefined, valueInt: field.valueInt ?? undefined,
valueDecimal: field.valueDecimal ?? undefined, valueDateTime: field.valueDateTime ?? undefined,
valueBool: field.valueBool ?? undefined, valueGuid: field.valueGuid ?? undefined, valueJson: field.valueJson,
});
export const eventReplacement = (current: CalendarEventDetail): CalendarEventWrite => ({
expectedUpdatedUtc: current.updatedUtc, title: current.title, description: current.description || "",
startUtc: current.startUtc, endUtc: current.endUtc, isAllDay: current.isAllDay,
locationId: current.locationId || undefined, locationText: current.locationText || "",
ownerUserId: current.ownerUserId || undefined, colorKey: current.colorKey || "",
recurrenceRule: current.recurrenceRule || "", recurrenceEnd: current.recurrenceEnd || undefined,
reminderMinutes: current.reminderMinutes || 0, isPrivate: current.isPrivate,
videoConferenceUrl: current.videoConferenceUrl || "", dynamicFields: current.dynamicFields.map(fieldInput),
});
export function createPlanningSession() {
const start = new Date(Date.now() + 86_400_000);
return createCalendarEvent({ title: "Planning session", startUtc: start.toISOString(), endUtc: new Date(start.getTime() + 3_600_000).toISOString(), reminderMinutes: 15, isPrivate: false });
}
export async function renameEvent(current: CalendarEventDetail, title: string) {
await updateCalendarEvent(current.recordId, { ...eventReplacement(current), title });
return getCalendarEvent(current.recordId);
}
export async function toggleEventFavorite(current: CalendarEventDetail) {
await setCalendarFavorite(current.recordId, !current.isFavorite);
return getCalendarEvent(current.recordId);
}
Optional starter styling
.custom-calendar { width: 100%; max-width: none; }
.custom-calendar form, .custom-calendar nav { align-items: end; display: flex; gap: .75rem; }
.custom-calendar ol { list-style: none; margin: 1rem 0; padding: 0; }
.custom-calendar li button { background: transparent; border: 0; display: grid; gap: .2rem; padding: .75rem 0; text-align: left; width: 100%; }
.custom-calendar article { border: 1px solid var(--line, #d8dee8); margin-top: 1rem; padding: 1rem; }
Exact data path
| Purpose | Route | Procedure |
|---|---|---|
| Window/list | GET /api/modules/calendar | sp_Calendar_ListSecured |
| Detail | GET /api/modules/calendar/{recordId} | sp_Calendar_GetSecured |
| Create | POST /api/modules/calendar | sp_Calendar_CreateSecured |
| Replace | PUT /api/modules/calendar/{recordId} | sp_Calendar_UpdateSecured |
| Favorite | PUT /api/modules/calendar/{recordId}/favorite | sp_Calendar_SetFavoriteSecured |
Attendees and resource bookings shown in detail remain their canonical secured records. This route set does not expose a separate attendee/resource write operation, so a custom page must not invent one. Use Reservations or the owning workflow where those links are created.
Build a professional Calendar dashboard
These six registry-backed presentation blocks let a designer turn the secured Calendar API into a complete admin page without writing a chart, grid, status badge, empty state, or timeline from scratch. The normal operational API begins at /api/modules/calendar; a dashboard-wide count or trend should come from a separate purpose-built presentation binding so the browser never downloads a broad record population to calculate one number.
The ZIP contains this feature's fictional design composition at pages/first-class/calendar-sample.json, its executable authenticated page at pages/first-class/calendar-live.json, and the catalog-driven FirstClassPresentationGallery.tsx. Use the sample only for visual design. The live page calls the real native API and reviewed stored procedures under the signed-in user's scope.
| Payload kind | Runtime block | Useful Calendar projection |
|---|---|---|
metric-set | core.metric-strip | Today, upcoming, response, reminder, and conflict counts |
entity-list | core.entity-list | Next visible events with time and location |
progress-list | core.progress-list | Attendance, event state, or resource utilization |
series-chart | core.series-chart | Created, scheduled, completed, and cancelled events |
data-grid | core.presentation-grid | One explicit date-window page of events |
timeline | core.timeline | Creation, reschedule, attendance, reminder, resource, and cancellation |
Copy/paste design preview: all six blocks
This complete static page document renders immediately in Puck/Monaco and is useful while styling a template. Its names, counts, dates, and IDs are fictional design fixtures; static preview values are not live tenant facts.
Copy the complete six-block page JSON
{
"blocks": [
{
"_id": "calendar-metrics",
"_type": "core.metric-strip",
"props": {
"title": "Calendar overview",
"asOfUtc": "2026-09-04T18:00:00Z",
"items": [
{
"key": "today",
"label": "Events today",
"value": 12,
"format": "number",
"tone": "primary"
},
{
"key": "week",
"label": "Next 7 days",
"value": 47,
"format": "number",
"tone": "neutral"
},
{
"key": "responses",
"label": "Awaiting response",
"value": 6,
"format": "number",
"tone": "warning"
},
{
"key": "conflicts",
"label": "Resource conflicts",
"value": 1,
"format": "number",
"tone": "danger"
}
]
},
"children": []
},
{
"_id": "calendar-recent",
"_type": "core.entity-list",
"props": {
"title": "Next events",
"hasMore": true,
"items": [
{
"id": "calendar-sample-1",
"recordId": "calendar-record-1",
"primary": "Field team dispatch",
"secondary": "Sep 5, 8:30 AM - Las Vegas",
"status": {
"key": "confirmed",
"label": "Confirmed",
"tone": "success"
},
"trailing": "8 attendees"
},
{
"id": "calendar-sample-2",
"recordId": "calendar-record-2",
"primary": "Field team dispatch - Follow-up",
"secondary": "Sep 5, 8:30 AM - Las Vegas - Updated two hours ago by the assigned owner",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"trailing": "Today"
},
{
"id": "calendar-sample-3",
"recordId": "calendar-record-3",
"primary": "Field team dispatch - West region",
"secondary": "Sep 5, 8:30 AM - Las Vegas - Related to three visible records at the Reno location",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"trailing": "3 related"
},
{
"id": "calendar-sample-4",
"recordId": "calendar-record-4",
"primary": "Field team dispatch - Customer response",
"secondary": "Sep 5, 8:30 AM - Las Vegas - Waiting for an external response before work can continue",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"trailing": "Tomorrow"
},
{
"id": "calendar-sample-5",
"recordId": "calendar-record-5",
"primary": "Field team dispatch - Regional operations review with a deliberately long title",
"secondary": "Sep 5, 8:30 AM - Las Vegas - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"trailing": "Review",
"tertiary": "Long-content fixture: verify keyboard focus, wrapping, narrow columns, and mobile overflow before publishing."
},
{
"id": "calendar-sample-6",
"recordId": "calendar-record-6",
"primary": "Field team dispatch - Completed preview",
"secondary": "Sep 5, 8:30 AM - Las Vegas - Closed after review with its related evidence retained",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"trailing": "Closed"
}
]
},
"children": []
},
{
"_id": "calendar-bystatus",
"_type": "core.progress-list",
"props": {
"title": "Events by response",
"items": [
{
"key": "accepted",
"label": "Accepted",
"value": 34,
"maximum": 47,
"displayValue": "34",
"tone": "success",
"status": {
"key": "accepted",
"label": "Accepted",
"tone": "success"
}
},
{
"key": "tentative",
"label": "Tentative",
"value": 7,
"maximum": 47,
"displayValue": "7",
"tone": "warning",
"status": {
"key": "tentative",
"label": "Tentative",
"tone": "warning"
}
},
{
"key": "pending",
"label": "No response",
"value": 6,
"maximum": 47,
"displayValue": "6",
"tone": "neutral",
"status": {
"key": "pending",
"label": "No response",
"tone": "neutral"
}
}
]
},
"children": []
},
{
"_id": "calendar-trend",
"_type": "core.series-chart",
"props": {
"title": "Schedule volume",
"variant": "bar",
"defaultPeriodKey": "d7",
"periods": [
{
"key": "d7",
"label": "7 days",
"labels": [
"Fri",
"Sat",
"Sun",
"Mon",
"Tue",
"Wed",
"Thu"
],
"series": [
{
"key": "primary",
"label": "Created",
"tone": "primary",
"values": [
8,
5,
4,
12,
15,
11,
17
]
},
{
"key": "secondary",
"label": "Completed",
"tone": "success",
"values": [
6,
4,
3,
9,
12,
10,
14
]
}
]
}
]
},
"children": []
},
{
"_id": "calendar-table",
"_type": "core.presentation-grid",
"props": {
"title": "Upcoming schedule",
"columns": [
{
"key": "event",
"label": "Event",
"type": "text",
"align": "left"
},
{
"key": "start",
"label": "Start",
"type": "date",
"align": "left"
},
{
"key": "location",
"label": "Location",
"type": "text",
"align": "left"
},
{
"key": "status",
"label": "Status",
"type": "status",
"align": "left"
},
{
"key": "attendees",
"label": "Attendees",
"type": "number",
"align": "right"
}
],
"rows": [
{
"id": "calendar-row-1",
"recordId": "calendar-record-1",
"cells": {
"event": "Field team dispatch",
"start": "2026-09-05T15:30:00Z",
"location": "Las Vegas",
"status": {
"key": "confirmed",
"label": "Confirmed",
"tone": "success"
},
"attendees": 8
}
},
{
"id": "calendar-row-2",
"recordId": "calendar-record-2",
"cells": {
"event": "Field team dispatch - Follow-up",
"start": "2026-09-04T15:42:00Z",
"location": "Las Vegas",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"attendees": 9
}
},
{
"id": "calendar-row-3",
"recordId": "calendar-record-3",
"cells": {
"event": "Field team dispatch - West region",
"start": "2026-09-04T12:18:00Z",
"location": "Las Vegas",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"attendees": 10
}
},
{
"id": "calendar-row-4",
"recordId": "calendar-record-4",
"cells": {
"event": "Field team dispatch - Customer response",
"start": "2026-09-03T21:07:00Z",
"location": "Las Vegas",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"attendees": 11
}
},
{
"id": "calendar-row-5",
"recordId": "calendar-record-5",
"cells": {
"event": "Field team dispatch - Regional operations review with a deliberately long title",
"start": "2026-09-03T16:31:00Z",
"location": "Las Vegas - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"attendees": 12
}
},
{
"id": "calendar-row-6",
"recordId": "calendar-record-6",
"cells": {
"event": "Field team dispatch - Completed preview",
"start": "2026-09-02T19:14:00Z",
"location": "Las Vegas",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"attendees": 13
}
}
],
"page": {
"pageNumber": 1,
"pageSize": 6,
"totalRecords": 12,
"totalIsExact": true,
"hasMore": true
}
},
"children": []
},
{
"_id": "calendar-timeline",
"_type": "core.timeline",
"props": {
"title": "Calendar activity",
"hasMore": true,
"items": [
{
"id": "calendar-event-1",
"recordId": "calendar-record-1",
"occurredUtc": "2026-09-04T17:58:00Z",
"title": "Attendee accepted",
"description": "Maya Chen accepted Field team dispatch.",
"actor": "Maya Chen",
"tone": "success"
},
{
"id": "calendar-event-2",
"recordId": "calendar-record-2",
"occurredUtc": "2026-09-04T15:42:00Z",
"title": "Attendee accepted - Follow-up",
"description": "Maya Chen accepted Field team dispatch. Updated two hours ago by the assigned owner.",
"actor": "Avery Patel",
"tone": "primary"
},
{
"id": "calendar-event-3",
"recordId": "calendar-record-3",
"occurredUtc": "2026-09-04T12:18:00Z",
"title": "Attendee accepted - West region",
"description": "Maya Chen accepted Field team dispatch. Related to three visible records at the Reno location.",
"actor": "Sam Rivera",
"tone": "success"
},
{
"id": "calendar-event-4",
"recordId": "calendar-record-4",
"occurredUtc": "2026-09-03T21:07:00Z",
"title": "Attendee accepted - Customer response",
"description": "Maya Chen accepted Field team dispatch. Waiting for an external response before work can continue.",
"actor": "Maya Chen",
"tone": "warning"
},
{
"id": "calendar-event-5",
"recordId": "calendar-record-5",
"occurredUtc": "2026-09-03T16:31:00Z",
"title": "Attendee accepted - Regional operations review with a deliberately long title",
"description": "Maya Chen accepted Field team dispatch. This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"actor": "Automation",
"tone": "danger"
},
{
"id": "calendar-event-6",
"recordId": "calendar-record-6",
"occurredUtc": "2026-09-02T19:14:00Z",
"title": "Attendee accepted - Completed preview",
"description": "Maya Chen accepted Field team dispatch. Closed after review with its related evidence retained.",
"actor": "Jordan Lee",
"tone": "success"
}
]
},
"children": []
}
]
}Copy/paste React composition
Copy InteractivePresentationComponents.tsx and its optional CSS from the Professional Foundation Developer Kit, then add this module-specific composition. It is semantic and unstyled by default; pass styled after importing interactive-presentation-components.css for the supplied polished foundation. Either version accepts only a bounded already-authorized document and fails closed through the shared strict parsers.
import {
EntityList,
MetricStrip,
PresentationGrid,
ProgressList,
SeriesChart,
Timeline,
} from "./InteractivePresentationComponents";
export interface CalendarPresentationData {
readonly metrics: unknown;
readonly recent: unknown;
readonly byStatus: unknown;
readonly trend: unknown;
readonly table: unknown;
readonly timeline: unknown;
}
export interface CalendarPresentationProps {
/** Pass only the already-authorized presentation document returned by the API. */
readonly data?: CalendarPresentationData | null;
readonly loading?: boolean;
readonly error?: boolean;
readonly styled?: boolean;
/** Record identity is navigation context; the detail API must authorize it again. */
readonly onOpenRecord?: (recordId: string) => void;
}
export function CalendarPresentation({
data,
loading = false,
error = false,
styled = false,
onOpenRecord,
}: CalendarPresentationProps) {
if (error) return <p role="alert">The Calendar presentation could not be loaded.</p>;
if (loading || !data) return <p role="status">Loading Calendar presentation...</p>;
return (
<main className={styled ? "bwhq-api-example" : undefined}>
<header>
<p>Calendar</p>
<h1>Calendar overview</h1>
<p>Upcoming events, attendance, resource demand, reminders, and schedule changes.</p>
</header>
<MetricStrip data={data.metrics} styled={styled} />
<div className={styled ? "bwhq-api-example__split" : undefined}>
<ProgressList data={data.byStatus} styled={styled} />
<EntityList data={data.recent} styled={styled} onOpenRecord={onOpenRecord} />
</div>
<SeriesChart data={data.trend} styled={styled} />
<PresentationGrid data={data.table} styled={styled} onOpenRecord={onOpenRecord} />
<Timeline data={data.timeline} styled={styled} onOpenRecord={onOpenRecord} />
</main>
);
}
Copy the live, authenticated module page
This document has no placeholder key and needs no invented endpoint. Save it to a page and add that page to a User Type menu. The registered native component calls /api/modules/calendar, uses the current tenant session, and preserves the module's real list, detail, create/update, pagination, empty, loading, and error behavior. Dynamic Records discovers the organization's real tenant-owned modules when no module key is configured.
Copy the executable live page
{
"blocks": [
{
"_id": "calendar-live",
"_type": "module.calendar",
"props": {},
"children": []
}
]
}The server derives SaaS app, organization, user, DataRoles, locations, module-specific membership/privacy, and any AI-read gate from verified identity. A returned identifier can select a detail target, but the detail or write endpoint authorizes it again. Page layout, status, tone, totals, action names, and identifiers never grant authority.
Decide which dates are true events and which are merely fields. Use Calendar when users need scheduling, attendance, reminders, or resource coordination.
A renderer being bundled in the tenant application does not make its data visible in every app. The server returns only components and records authorized for the current app and signed-in user; unavailable or unauthorized blocks fail closed.
Capability review: 2026-09-14. For exact current technical availability, use the generated API Map and first-class module inventory.