diff --git a/docker-compose.yml b/docker-compose.yml
index d81f6479c8..e8e15c02d2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -97,6 +97,27 @@ services:
retries: 3
start_period: 10s
+ # ============================================================================
+ # calendar_service (Port 8101)
+ # Calendar events + attendees - MacroDB
+ # ============================================================================
+ calendar_service:
+ <<: [*common-env, *rust-services-image]
+ command: ["/app/out/calendar_service"]
+ ports:
+ - "8101:8080"
+ networks:
+ databases:
+ services:
+ aliases:
+ - calendar-service
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 10s
+
# ============================================================================
# document_cognition_service (Port 8085)
# Document analysis and processing
diff --git a/js/app/packages/app/component/app-sidebar/sidebar.tsx b/js/app/packages/app/component/app-sidebar/sidebar.tsx
index 4de83297a0..bce83ae4df 100644
--- a/js/app/packages/app/component/app-sidebar/sidebar.tsx
+++ b/js/app/packages/app/component/app-sidebar/sidebar.tsx
@@ -57,6 +57,7 @@ import { isTouchDevice } from '@core/mobile/isTouchDevice';
import LogoIcon from '@icon/macro-logo.svg';
import { AnimatedSquareCommandKIcon } from '@icon/square-command-k';
import { AnimatedSquareSidebarIcon } from '@icon/square-sidebar';
+import { AnimatedCalendarIcon } from '@icon/wide-calendar';
import { AnimatedCallIcon } from '@icon/wide-call';
import { AnimatedChannelIcon } from '@icon/wide-channel';
import { AnimatedEmailIcon } from '@icon/wide-email';
@@ -773,6 +774,15 @@ const DASHBOARD_LINK: SidebarItem = {
hotkeyToken: TOKENS.sidebar.goTo.home,
};
+const CALENDAR_LINK: SidebarItem = {
+ id: 'calendar',
+ label: 'Calendar',
+ href: '/calendar',
+ icon: AnimatedCalendarIcon,
+ hotkey: 'd',
+ hotkeyToken: TOKENS.sidebar.goTo.calendar,
+};
+
export const AppSidebar = (props: AppSidebarProps) => {
const analytics = useAnalytics();
const layout = useSplitLayout();
@@ -823,6 +833,8 @@ export const AppSidebar = (props: AppSidebarProps) => {
links = [...links.slice(0, idx + 1), CALLS_LINK, ...links.slice(idx + 1)];
}
+ links = [...links, CALENDAR_LINK];
+
return links;
});
diff --git a/js/app/packages/app/component/split-layout/componentRegistry.tsx b/js/app/packages/app/component/split-layout/componentRegistry.tsx
index fd776713de..3fbcb4f7bf 100644
--- a/js/app/packages/app/component/split-layout/componentRegistry.tsx
+++ b/js/app/packages/app/component/split-layout/componentRegistry.tsx
@@ -4,6 +4,7 @@ import { Home } from '@app/component/home';
import type { SetPredicatesInput } from '@app/component/next-soup/filters/filter-store/predicates-store';
import type { Query } from '@app/component/next-soup/filters/filter-store/types';
import { SoupView } from '@app/component/next-soup/soup-view/soup-view';
+import { Calendar } from '@block-calendar';
import { ChannelCompose } from '@block-channel/component/Compose';
import { ComposeTask } from '@block-md/component/ComposeTask';
import { useIsAuthenticated } from '@core/auth';
@@ -280,6 +281,13 @@ registerComponent(
);
})
);
+registerComponent(
+ 'calendar',
+ withAuth(() => {
+ usePageViewTracking('calendar');
+ return ;
+ })
+);
/** END - APP ROUTES */
registerComponent('loading', () => );
diff --git a/js/app/packages/block-calendar/README.md b/js/app/packages/block-calendar/README.md
new file mode 100644
index 0000000000..0d0f7e367e
--- /dev/null
+++ b/js/app/packages/block-calendar/README.md
@@ -0,0 +1,44 @@
+# block-calendar
+
+A Google-Calendar-style calendar surface, opened from the sidebar ("Calendar").
+
+## Views
+
+- **Week** (`w`) — 7-day time grid (default)
+- **Day** (`d`) — single-day time grid
+- **List** (`l`) — agenda of upcoming events grouped by day
+
+## Keyboard shortcuts
+
+Active while the calendar is focused:
+
+| Key | Action |
+| --- | ----------------- |
+| `j` | Next screen |
+| `k` | Previous screen |
+| `t` | Jump to today |
+| `n` | New event |
+| `w` | Week view |
+| `d` | Day view |
+| `l` | List view |
+
+`g d` (go-to leader) opens the calendar from anywhere.
+
+## Events & invites
+
+Events are persisted by the `calendar_service` backend
+(`rust/cloud-storage/calendar`). Data access lives in `@queries/calendar`; the
+wire client is `@service-calendar`.
+
+Click an empty slot to create an event, or an event to edit it. Add guests by
+email; **Save & send invites** records the invite on the backend and emails
+attendees from the user's connected mailbox (via the email service), including
+an `.ics` payload. The dialog can also download a standalone `.ics`.
+
+## Layers
+
+- `model/` — frontend domain types (instant-based, decoupled from the wire DTOs)
+- `util/` — date math, iCalendar generation, invite-email composition
+- `component/` — `Calendar` (orchestrator + hotkeys), `Toolbar`, `TimeGrid`
+ (week/day), `ListView`, `EventDialog`, and the `CalendarContext` that wires
+ state to queries/mutations.
diff --git a/js/app/packages/block-calendar/component/Calendar.tsx b/js/app/packages/block-calendar/component/Calendar.tsx
new file mode 100644
index 0000000000..5e13d80db0
--- /dev/null
+++ b/js/app/packages/block-calendar/component/Calendar.tsx
@@ -0,0 +1,99 @@
+import {
+ createHotkeyGroup,
+ registerHotkey,
+ useHotkeyDOMScope,
+} from '@core/hotkey/hotkeys';
+import { type HotkeyToken, TOKENS } from '@core/hotkey/tokens';
+import type { ValidHotkey } from '@core/hotkey/types';
+import { Match, onCleanup, onMount, Switch } from 'solid-js';
+import { daysForView } from '../util/dates';
+import { CalendarProvider, useCalendar } from './CalendarContext';
+import { EventDialog } from './EventDialog';
+import { ListView } from './ListView';
+import { TimeGrid } from './TimeGrid';
+import { Toolbar } from './Toolbar';
+
+function CalendarInner() {
+ const calendar = useCalendar();
+ const [attachHotkeys, scopeId] = useHotkeyDOMScope('calendar');
+ const group = createHotkeyGroup();
+ let rootRef: HTMLDivElement | undefined;
+
+ onMount(() => {
+ if (rootRef) {
+ attachHotkeys(rootRef);
+ // Focus the root so j/k work without an explicit click first.
+ rootRef.focus();
+ }
+
+ const bind = (
+ hotkey: ValidHotkey,
+ hotkeyToken: HotkeyToken,
+ description: string,
+ run: () => void
+ ) =>
+ group.add(
+ registerHotkey({
+ hotkey,
+ scopeId,
+ hotkeyToken,
+ description,
+ keyDownHandler: () => {
+ run();
+ return true;
+ },
+ })
+ );
+
+ // Primary navigation: vim-style j/k step one screen forward/back, matching
+ // the soup list convention (j = next, k = previous).
+ bind('j', TOKENS.calendar.next, 'Next', calendar.goNext);
+ bind('k', TOKENS.calendar.prev, 'Previous', calendar.goPrev);
+ bind('t', TOKENS.calendar.today, 'Today', calendar.goToday);
+ bind('n', TOKENS.calendar.newEvent, 'New event', () => calendar.openNew());
+ bind('d', TOKENS.calendar.viewDay, 'Day view', () =>
+ calendar.setView('day')
+ );
+ bind('w', TOKENS.calendar.viewWeek, 'Week view', () =>
+ calendar.setView('week')
+ );
+ bind('l', TOKENS.calendar.viewList, 'List view', () =>
+ calendar.setView('list')
+ );
+ });
+
+ onCleanup(() => group.dispose());
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** The calendar surface, opened from the sidebar. */
+export default function Calendar() {
+ return (
+
+
+
+ );
+}
diff --git a/js/app/packages/block-calendar/component/CalendarContext.tsx b/js/app/packages/block-calendar/component/CalendarContext.tsx
new file mode 100644
index 0000000000..42a6af0fc2
--- /dev/null
+++ b/js/app/packages/block-calendar/component/CalendarContext.tsx
@@ -0,0 +1,179 @@
+import { createAssertedContextProvider } from '@core/context/createContext';
+import { useUserContext } from '@core/context/user';
+import {
+ type CalendarRange,
+ useCalendarEventsQuery,
+ useCreateEventMutation,
+ useDeleteEventMutation,
+ useInviteAttendeesMutation,
+ useUpdateEventMutation,
+} from '@queries/calendar/events';
+import type { CalendarEvent as WireEvent } from '@service-calendar/generated/schemas';
+import { endOfDay, startOfDay } from 'date-fns';
+import { type Accessor, createMemo, createSignal } from 'solid-js';
+import {
+ type AttendeeStatus,
+ type CalendarEvent,
+ type CalendarEventDraft,
+ type CalendarViewMode,
+ isEventColor,
+} from '../model/types';
+import { DEFAULT_EVENT_MINUTES, daysForView, shiftAnchor } from '../util/dates';
+import { sendInviteEmail } from '../util/invite';
+
+function toDomain(event: WireEvent): CalendarEvent {
+ return {
+ id: event.id,
+ title: event.title,
+ description: event.description ?? undefined,
+ location: event.location ?? undefined,
+ startMs: event.start_ms,
+ endMs: event.end_ms,
+ allDay: event.all_day,
+ color: isEventColor(event.color) ? event.color : 'blue',
+ attendees: event.attendees.map((a) => ({
+ email: a.email,
+ name: a.name ?? undefined,
+ status: (a.status as AttendeeStatus) ?? 'pending',
+ })),
+ };
+}
+
+function draftToRequest(draft: CalendarEventDraft) {
+ return {
+ title: draft.title.trim() || 'Untitled event',
+ description: draft.description.trim() || null,
+ location: draft.location.trim() || null,
+ start_ms: draft.startMs,
+ end_ms: draft.endMs,
+ all_day: draft.allDay,
+ color: draft.color,
+ attendees: draft.attendees.map((a) => ({
+ email: a.email,
+ name: a.name ?? null,
+ })),
+ };
+}
+
+function eventToDraft(event: CalendarEvent): CalendarEventDraft {
+ return {
+ id: event.id,
+ title: event.title,
+ description: event.description ?? '',
+ location: event.location ?? '',
+ startMs: event.startMs,
+ endMs: event.endMs,
+ allDay: event.allDay,
+ attendees: [...event.attendees],
+ color: event.color,
+ };
+}
+
+function newDraft(startMs: number): CalendarEventDraft {
+ return {
+ title: '',
+ description: '',
+ location: '',
+ startMs,
+ endMs: startMs + DEFAULT_EVENT_MINUTES * 60 * 1000,
+ allDay: false,
+ attendees: [],
+ color: 'blue',
+ };
+}
+
+export const [CalendarProvider, useCalendar] = createAssertedContextProvider(
+ 'Calendar',
+ () => {
+ const user = useUserContext();
+
+ const [view, setView] = createSignal('week');
+ const [anchor, setAnchor] = createSignal(new Date());
+ const [editingDraft, setEditingDraft] =
+ createSignal(null);
+
+ const range = createMemo(() => {
+ const days = daysForView(view(), anchor());
+ return {
+ startMs: startOfDay(days[0]!).getTime(),
+ endMs: endOfDay(days[days.length - 1]!).getTime(),
+ };
+ });
+
+ const eventsQuery = useCalendarEventsQuery(range, () => true);
+
+ const events = createMemo(() =>
+ (eventsQuery.data ?? []).map(toDomain)
+ );
+
+ const createMutation = useCreateEventMutation();
+ const updateMutation = useUpdateEventMutation();
+ const deleteMutation = useDeleteEventMutation();
+ const inviteMutation = useInviteAttendeesMutation();
+
+ const goToday = () => setAnchor(new Date());
+ const goPrev = () => setAnchor((d) => shiftAnchor(view(), d, -1));
+ const goNext = () => setAnchor((d) => shiftAnchor(view(), d, 1));
+
+ const openNew = (startMs?: number) =>
+ setEditingDraft(newDraft(startMs ?? Date.now()));
+ const openEdit = (event: CalendarEvent) =>
+ setEditingDraft(eventToDraft(event));
+ const closeEditor = () => setEditingDraft(null);
+
+ /** Persists the current draft (create or update) and returns the saved event. */
+ const saveDraft = async (
+ draft: CalendarEventDraft
+ ): Promise => {
+ const body = draftToRequest(draft);
+ const saved = draft.id
+ ? await updateMutation.mutateAsync({ id: draft.id, body })
+ : await createMutation.mutateAsync(body);
+ return toDomain(saved);
+ };
+
+ const removeEvent = async (id: string) => {
+ await deleteMutation.mutateAsync({ id });
+ };
+
+ /**
+ * Records invites on the backend and emails attendees from the user's
+ * connected mailbox. Returns the email-service result.
+ */
+ const sendInvites = async (event: CalendarEvent, emails: string[]) => {
+ await inviteMutation.mutateAsync({ id: event.id, emails });
+ const recipients = event.attendees
+ .filter((a) => emails.includes(a.email))
+ .map((a) => ({ email: a.email, name: a.name }));
+ return sendInviteEmail({
+ event,
+ organizerEmail: user.email() ?? '',
+ organizerName: user.author(),
+ recipients: recipients.length > 0 ? recipients : undefined,
+ });
+ };
+
+ return {
+ view,
+ setView,
+ anchor,
+ setAnchor,
+ range,
+ events,
+ isLoading: () => eventsQuery.isLoading,
+ editingDraft: editingDraft as Accessor,
+ setEditingDraft,
+ goToday,
+ goPrev,
+ goNext,
+ openNew,
+ openEdit,
+ closeEditor,
+ saveDraft,
+ removeEvent,
+ sendInvites,
+ organizerEmail: () => user.email() ?? '',
+ organizerName: () => user.author(),
+ };
+ }
+);
diff --git a/js/app/packages/block-calendar/component/EventDialog.tsx b/js/app/packages/block-calendar/component/EventDialog.tsx
new file mode 100644
index 0000000000..ecabed2c96
--- /dev/null
+++ b/js/app/packages/block-calendar/component/EventDialog.tsx
@@ -0,0 +1,359 @@
+import DownloadIcon from '@phosphor/download-simple.svg';
+import SendIcon from '@phosphor/paper-plane-tilt.svg';
+import TrashIcon from '@phosphor/trash.svg';
+import XIcon from '@phosphor/x.svg';
+import { Button, cn, Dialog } from '@ui';
+import { createSignal, For, Show } from 'solid-js';
+import {
+ type CalendarEvent,
+ type CalendarEventDraft,
+ EVENT_COLORS,
+} from '../model/types';
+import {
+ DEFAULT_EVENT_MINUTES,
+ fromDatetimeLocalValue,
+ toDatetimeLocalValue,
+} from '../util/dates';
+import { downloadIcs } from '../util/ics';
+import { useCalendar } from './CalendarContext';
+import { EVENT_COLOR_CLASSES } from './colors';
+
+const FIELD =
+ 'w-full rounded-xs border border-edge-muted bg-surface px-2 py-1.5 text-sm text-ink placeholder:text-ink-extra-muted focus:border-accent focus:outline-none';
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+/** Builds a CalendarEvent view of the in-progress draft for ICS export. */
+function draftToEvent(draft: CalendarEventDraft): CalendarEvent {
+ return {
+ id: draft.id ?? 'draft',
+ title: draft.title,
+ description: draft.description || undefined,
+ location: draft.location || undefined,
+ startMs: draft.startMs,
+ endMs: draft.endMs,
+ allDay: draft.allDay,
+ attendees: draft.attendees,
+ color: draft.color,
+ };
+}
+
+export function EventDialog() {
+ const calendar = useCalendar();
+ const [busy, setBusy] = createSignal(false);
+ const [feedback, setFeedback] = createSignal(null);
+ const [attendeeInput, setAttendeeInput] = createSignal('');
+
+ const draft = () => calendar.editingDraft();
+
+ const update = (patch: Partial) =>
+ calendar.setEditingDraft((prev) => (prev ? { ...prev, ...patch } : prev));
+
+ const setStart = (value: string) => {
+ const startMs = fromDatetimeLocalValue(value);
+ const current = draft();
+ if (!current) return;
+ const endMs =
+ startMs >= current.endMs
+ ? startMs + DEFAULT_EVENT_MINUTES * 60 * 1000
+ : current.endMs;
+ update({ startMs, endMs });
+ };
+
+ const addAttendee = () => {
+ const email = attendeeInput().trim().toLowerCase();
+ const current = draft();
+ if (!current) return;
+ if (!EMAIL_RE.test(email)) {
+ setFeedback('Enter a valid email address');
+ return;
+ }
+ if (current.attendees.some((a) => a.email === email)) {
+ setAttendeeInput('');
+ return;
+ }
+ setFeedback(null);
+ update({ attendees: [...current.attendees, { email, status: 'pending' }] });
+ setAttendeeInput('');
+ };
+
+ const removeAttendee = (email: string) => {
+ const current = draft();
+ if (!current) return;
+ update({ attendees: current.attendees.filter((a) => a.email !== email) });
+ };
+
+ const close = () => {
+ setFeedback(null);
+ setAttendeeInput('');
+ calendar.closeEditor();
+ };
+
+ const onSave = async () => {
+ const current = draft();
+ if (!current) return;
+ setBusy(true);
+ try {
+ await calendar.saveDraft(current);
+ close();
+ } catch {
+ setFeedback('Failed to save event');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const onSaveAndInvite = async () => {
+ const current = draft();
+ if (!current) return;
+ setBusy(true);
+ try {
+ const saved = await calendar.saveDraft(current);
+ const emails = saved.attendees.map((a) => a.email);
+ if (emails.length > 0) {
+ const result = await calendar.sendInvites(saved, emails);
+ if (result.isErr()) {
+ setFeedback('Event saved, but invites could not be sent');
+ setBusy(false);
+ return;
+ }
+ }
+ close();
+ } catch {
+ setFeedback('Failed to save event');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const onDelete = async () => {
+ const current = draft();
+ if (!current?.id) return;
+ setBusy(true);
+ try {
+ await calendar.removeEvent(current.id);
+ close();
+ } catch {
+ setFeedback('Failed to delete event');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/js/app/packages/block-calendar/component/ListView.tsx b/js/app/packages/block-calendar/component/ListView.tsx
new file mode 100644
index 0000000000..e78ebfada2
--- /dev/null
+++ b/js/app/packages/block-calendar/component/ListView.tsx
@@ -0,0 +1,95 @@
+import UsersIcon from '@phosphor/users.svg';
+import { format, isSameDay } from 'date-fns';
+import { createMemo, For, Show } from 'solid-js';
+import type { CalendarEvent } from '../model/types';
+import { formatTimeRange } from '../util/dates';
+import { useCalendar } from './CalendarContext';
+import { EVENT_COLOR_CLASSES } from './colors';
+
+interface DayGroup {
+ day: Date;
+ events: CalendarEvent[];
+}
+
+export function ListView() {
+ const calendar = useCalendar();
+
+ const groups = createMemo(() => {
+ const sorted = [...calendar.events()].sort((a, b) => a.startMs - b.startMs);
+ const out: DayGroup[] = [];
+ for (const event of sorted) {
+ const day = new Date(event.startMs);
+ const last = out[out.length - 1];
+ if (last && isSameDay(last.day, day)) {
+ last.events.push(event);
+ } else {
+ out.push({ day, events: [event] });
+ }
+ }
+ return out;
+ });
+
+ return (
+
+
0}
+ fallback={
+
+ No events in this range.
+
+ }
+ >
+
+
+ {(group) => (
+
+
+
+ {format(group.day, 'EEEE')}
+
+
+ {format(group.day, 'MMMM d, yyyy')}
+
+
+
+
+ {(event) => (
+
+ )}
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/js/app/packages/block-calendar/component/TimeGrid.tsx b/js/app/packages/block-calendar/component/TimeGrid.tsx
new file mode 100644
index 0000000000..48485d7bec
--- /dev/null
+++ b/js/app/packages/block-calendar/component/TimeGrid.tsx
@@ -0,0 +1,213 @@
+import { endOfDay, format, isToday, startOfDay } from 'date-fns';
+import {
+ createMemo,
+ createSignal,
+ For,
+ onCleanup,
+ onMount,
+ Show,
+} from 'solid-js';
+import type { CalendarEvent } from '../model/types';
+import {
+ DAY_HEIGHT_PX,
+ durationHeightPx,
+ eventIntersectsDay,
+ formatHourLabel,
+ HOUR_HEIGHT_PX,
+ HOURS,
+ instantFromGridClick,
+ offsetTopPx,
+} from '../util/dates';
+import { useCalendar } from './CalendarContext';
+import { EVENT_COLOR_CLASSES } from './colors';
+
+/** Minutes-since-midnight → top pixels, used by the now-indicator. */
+function nowOffsetPx(now: number): number {
+ return offsetTopPx(now);
+}
+
+function EventBlock(props: { event: CalendarEvent; day: Date }) {
+ const calendar = useCalendar();
+ const dayStart = () => startOfDay(props.day).getTime();
+ const dayEnd = () => endOfDay(props.day).getTime();
+ const clampedStart = () => Math.max(props.event.startMs, dayStart());
+ const clampedEnd = () => Math.min(props.event.endMs, dayEnd());
+
+ return (
+
+ );
+}
+
+function DayColumn(props: { day: Date }) {
+ const calendar = useCalendar();
+
+ const timedEvents = createMemo(() =>
+ calendar
+ .events()
+ .filter(
+ (e) => !e.allDay && eventIntersectsDay(e.startMs, e.endMs, props.day)
+ )
+ );
+
+ let columnRef: HTMLDivElement | undefined;
+
+ const handleCreate = (e: MouseEvent) => {
+ if (!columnRef) return;
+ const rect = columnRef.getBoundingClientRect();
+ const y = e.clientY - rect.top;
+ calendar.openNew(instantFromGridClick(props.day, y));
+ };
+
+ return (
+
+
+ {(hour) => (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {(event) => }
+
+
+ );
+}
+
+function NowIndicator() {
+ const [now, setNow] = createSignal(Date.now());
+ onMount(() => {
+ const id = window.setInterval(() => setNow(Date.now()), 60_000);
+ onCleanup(() => window.clearInterval(id));
+ });
+ return (
+
+ );
+}
+
+function AllDayRow(props: { days: Date[] }) {
+ const calendar = useCalendar();
+ const allDayFor = (day: Date) =>
+ calendar
+ .events()
+ .filter((e) => e.allDay && eventIntersectsDay(e.startMs, e.endMs, day));
+
+ const hasAny = createMemo(() => calendar.events().some((e) => e.allDay));
+
+ return (
+
+
+
+
+ {(day) => (
+
+
+ {(event) => (
+
+ )}
+
+
+ )}
+
+
+
+ );
+}
+
+/** Week/day time grid. `days.length === 1` renders the day view. */
+export function TimeGrid(props: { days: Date[] }) {
+ return (
+
+ {/* Column headers */}
+
+
+
+ {(day) => (
+
+
+ {format(day, 'EEE')}
+
+
+ {format(day, 'd')}
+
+
+ )}
+
+
+
+
+
+ {/* Scrollable time grid */}
+
+
+ {/* Hour gutter */}
+
+
+ {(hour) => (
+
+ )}
+
+
+
+
{(day) => }
+
+
+
+ );
+}
diff --git a/js/app/packages/block-calendar/component/Toolbar.tsx b/js/app/packages/block-calendar/component/Toolbar.tsx
new file mode 100644
index 0000000000..a94e0055fb
--- /dev/null
+++ b/js/app/packages/block-calendar/component/Toolbar.tsx
@@ -0,0 +1,68 @@
+import CaretLeftIcon from '@phosphor/caret-left.svg';
+import CaretRightIcon from '@phosphor/caret-right.svg';
+import PlusIcon from '@phosphor/plus.svg';
+import { Button, SegmentedControl } from '@ui';
+import type { CalendarViewMode } from '../model/types';
+import { formatViewTitle } from '../util/dates';
+import { useCalendar } from './CalendarContext';
+
+const VIEW_OPTIONS: { value: CalendarViewMode; label: string }[] = [
+ { value: 'day', label: 'Day' },
+ { value: 'week', label: 'Week' },
+ { value: 'list', label: 'List' },
+];
+
+export function Toolbar() {
+ const calendar = useCalendar();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {formatViewTitle(calendar.view(), calendar.anchor())}
+
+
+
+
+
+
+ );
+}
diff --git a/js/app/packages/block-calendar/component/colors.ts b/js/app/packages/block-calendar/component/colors.ts
new file mode 100644
index 0000000000..a0f77fc11a
--- /dev/null
+++ b/js/app/packages/block-calendar/component/colors.ts
@@ -0,0 +1,43 @@
+import type { EventColor } from '../model/types';
+
+/**
+ * Static class strings per accent color. They must be full literals (not
+ * interpolated) so Tailwind's scanner keeps them. Event color-coding is
+ * inherently multi-hue, so this is a deliberate, contained use of raw palette
+ * classes rather than semantic tokens.
+ */
+export const EVENT_COLOR_CLASSES: Record<
+ EventColor,
+ { block: string; dot: string; swatch: string }
+> = {
+ blue: {
+ block: 'bg-blue-500/15 border-l-2 border-blue-500 text-ink',
+ dot: 'bg-blue-500',
+ swatch: 'bg-blue-500',
+ },
+ green: {
+ block: 'bg-green-500/15 border-l-2 border-green-500 text-ink',
+ dot: 'bg-green-500',
+ swatch: 'bg-green-500',
+ },
+ purple: {
+ block: 'bg-purple-500/15 border-l-2 border-purple-500 text-ink',
+ dot: 'bg-purple-500',
+ swatch: 'bg-purple-500',
+ },
+ orange: {
+ block: 'bg-orange-500/15 border-l-2 border-orange-500 text-ink',
+ dot: 'bg-orange-500',
+ swatch: 'bg-orange-500',
+ },
+ red: {
+ block: 'bg-red-500/15 border-l-2 border-red-500 text-ink',
+ dot: 'bg-red-500',
+ swatch: 'bg-red-500',
+ },
+ pink: {
+ block: 'bg-pink-500/15 border-l-2 border-pink-500 text-ink',
+ dot: 'bg-pink-500',
+ swatch: 'bg-pink-500',
+ },
+};
diff --git a/js/app/packages/block-calendar/index.ts b/js/app/packages/block-calendar/index.ts
new file mode 100644
index 0000000000..41061cfc41
--- /dev/null
+++ b/js/app/packages/block-calendar/index.ts
@@ -0,0 +1,6 @@
+export { default as Calendar } from './component/Calendar';
+export type {
+ CalendarEvent,
+ CalendarViewMode,
+ EventColor,
+} from './model/types';
diff --git a/js/app/packages/block-calendar/model/types.ts b/js/app/packages/block-calendar/model/types.ts
new file mode 100644
index 0000000000..efe1a01e3d
--- /dev/null
+++ b/js/app/packages/block-calendar/model/types.ts
@@ -0,0 +1,70 @@
+/**
+ * Frontend domain types for the calendar block.
+ *
+ * These are intentionally decoupled from the wire/API types (which live in
+ * `@service-calendar`). Queries map between the two so the UI works with a
+ * clean, instant-based model.
+ */
+
+/** The three calendar surfaces, mirroring Google Calendar. */
+export type CalendarViewMode = 'week' | 'day' | 'list';
+
+export const CALENDAR_VIEW_MODES: CalendarViewMode[] = ['week', 'day', 'list'];
+
+/** Accent palette for events. Keys map to semantic-ish token classes in the UI. */
+export type EventColor =
+ | 'blue'
+ | 'green'
+ | 'purple'
+ | 'orange'
+ | 'red'
+ | 'pink';
+
+export const EVENT_COLORS: EventColor[] = [
+ 'blue',
+ 'green',
+ 'purple',
+ 'orange',
+ 'red',
+ 'pink',
+];
+
+/** RSVP state of an invitee. */
+export type AttendeeStatus = 'pending' | 'accepted' | 'declined' | 'tentative';
+
+export interface CalendarAttendee {
+ email: string;
+ name?: string;
+ status: AttendeeStatus;
+}
+
+export interface CalendarEvent {
+ id: string;
+ title: string;
+ description?: string;
+ location?: string;
+ /** Start instant, epoch milliseconds (UTC). */
+ startMs: number;
+ /** End instant, epoch milliseconds (UTC). */
+ endMs: number;
+ allDay: boolean;
+ attendees: CalendarAttendee[];
+ color: EventColor;
+}
+
+/** Shape used by the create/edit form before it becomes a persisted event. */
+export interface CalendarEventDraft {
+ id?: string;
+ title: string;
+ description: string;
+ location: string;
+ startMs: number;
+ endMs: number;
+ allDay: boolean;
+ attendees: CalendarAttendee[];
+ color: EventColor;
+}
+
+export function isEventColor(value: string): value is EventColor {
+ return (EVENT_COLORS as string[]).includes(value);
+}
diff --git a/js/app/packages/block-calendar/package.json b/js/app/packages/block-calendar/package.json
new file mode 100644
index 0000000000..a8ee0bcba4
--- /dev/null
+++ b/js/app/packages/block-calendar/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "block-calendar",
+ "private": true,
+ "scripts": {},
+ "dependencies": {}
+}
diff --git a/js/app/packages/block-calendar/tsconfig.json b/js/app/packages/block-calendar/tsconfig.json
new file mode 100644
index 0000000000..3a9c6c75eb
--- /dev/null
+++ b/js/app/packages/block-calendar/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "verbatimModuleSyntax": true,
+ "declaration": true,
+ "composite": true
+ }
+}
diff --git a/js/app/packages/block-calendar/util/dates.ts b/js/app/packages/block-calendar/util/dates.ts
new file mode 100644
index 0000000000..899d058772
--- /dev/null
+++ b/js/app/packages/block-calendar/util/dates.ts
@@ -0,0 +1,150 @@
+/**
+ * Thin date helpers for the calendar grid, built on `date-fns`.
+ *
+ * All persisted events store UTC epoch-millis (`startMs`/`endMs`); rendering
+ * and editing happen in the browser's local timezone via the native `Date`.
+ */
+import {
+ addDays,
+ addWeeks,
+ differenceInMinutes,
+ eachDayOfInterval,
+ endOfDay,
+ endOfWeek,
+ format,
+ setHours,
+ setMinutes,
+ startOfDay,
+ startOfWeek,
+} from 'date-fns';
+import type { CalendarViewMode } from '../model/types';
+
+const MINUTES_PER_DAY = 24 * 60;
+/** Vertical pixels per hour in the time grid. */
+export const HOUR_HEIGHT_PX = 48;
+export const DAY_HEIGHT_PX = HOUR_HEIGHT_PX * 24;
+/** Default new-event duration. */
+export const DEFAULT_EVENT_MINUTES = 60;
+/** Snap granularity (minutes) when clicking the grid. */
+const SLOT_MINUTES = 30;
+
+export const HOURS = Array.from({ length: 24 }, (_, i) => i);
+
+/** Week starts on Sunday to match Google Calendar's default. */
+const WEEK_OPTS = { weekStartsOn: 0 } as const;
+
+/** The list of day-Dates visible for a given view + anchor date. */
+export function daysForView(view: CalendarViewMode, anchor: Date): Date[] {
+ switch (view) {
+ case 'day':
+ return [startOfDay(anchor)];
+ case 'week':
+ return eachDayOfInterval({
+ start: startOfWeek(anchor, WEEK_OPTS),
+ end: endOfWeek(anchor, WEEK_OPTS),
+ });
+ case 'list':
+ // List shows a 30-day rolling window starting at the anchor day.
+ return eachDayOfInterval({
+ start: startOfDay(anchor),
+ end: endOfDay(addDays(anchor, 29)),
+ });
+ }
+}
+
+/** Step the anchor forward/backward by one "screen" for the active view. */
+export function shiftAnchor(
+ view: CalendarViewMode,
+ anchor: Date,
+ direction: 1 | -1
+): Date {
+ switch (view) {
+ case 'day':
+ return addDays(anchor, direction);
+ case 'week':
+ return addWeeks(anchor, direction);
+ case 'list':
+ // Page the rolling window by its full length.
+ return addDays(anchor, direction * 30);
+ }
+}
+
+/** Minutes from local midnight for an instant. */
+function minutesIntoDay(ms: number): number {
+ const d = new Date(ms);
+ return d.getHours() * 60 + d.getMinutes();
+}
+
+/** Pixel offset from the top of a day column for an instant. */
+export function offsetTopPx(ms: number): number {
+ return (minutesIntoDay(ms) / 60) * HOUR_HEIGHT_PX;
+}
+
+/** Pixel height for a duration, clamped to a minimum so short events stay legible. */
+export function durationHeightPx(startMs: number, endMs: number): number {
+ const mins = Math.max(differenceInMinutes(endMs, startMs), 1);
+ return Math.max((mins / 60) * HOUR_HEIGHT_PX, 14);
+}
+
+/** Snap a click at `pixelY` within a `day` column to a slot-aligned instant. */
+export function instantFromGridClick(day: Date, pixelY: number): number {
+ const rawMinutes = (pixelY / HOUR_HEIGHT_PX) * 60;
+ const snapped = Math.round(rawMinutes / SLOT_MINUTES) * SLOT_MINUTES;
+ const clamped = Math.max(
+ 0,
+ Math.min(snapped, MINUTES_PER_DAY - SLOT_MINUTES)
+ );
+ return setMinutes(setHours(startOfDay(day), 0), clamped).getTime();
+}
+
+/** True when the event's local-time span intersects the given local day. */
+export function eventIntersectsDay(
+ startMs: number,
+ endMs: number,
+ day: Date
+): boolean {
+ const dayStart = startOfDay(day).getTime();
+ const dayEnd = endOfDay(day).getTime();
+ return startMs < dayEnd && endMs > dayStart;
+}
+
+// --- Formatting -----------------------------------------------------------
+
+export function formatHourLabel(hour: number): string {
+ if (hour === 0) return '12 AM';
+ if (hour === 12) return '12 PM';
+ return hour < 12 ? `${hour} AM` : `${hour - 12} PM`;
+}
+
+export function formatTimeRange(startMs: number, endMs: number): string {
+ return `${format(startMs, 'h:mm a')} – ${format(endMs, 'h:mm a')}`;
+}
+
+export function formatViewTitle(view: CalendarViewMode, anchor: Date): string {
+ switch (view) {
+ case 'day':
+ return format(anchor, 'EEEE, MMMM d, yyyy');
+ case 'week': {
+ const start = startOfWeek(anchor, WEEK_OPTS);
+ const end = endOfWeek(anchor, WEEK_OPTS);
+ if (start.getMonth() === end.getMonth()) {
+ return `${format(start, 'MMMM yyyy')}`;
+ }
+ return `${format(start, 'MMM')} – ${format(end, 'MMM yyyy')}`;
+ }
+ case 'list':
+ return `${format(anchor, 'MMM d')} – ${format(addDays(anchor, 29), 'MMM d, yyyy')}`;
+ }
+}
+
+/** `datetime-local` input value (local time, no timezone suffix). */
+export function toDatetimeLocalValue(ms: number): string {
+ return format(ms, "yyyy-MM-dd'T'HH:mm");
+}
+
+/** Parse a `datetime-local` input value back into epoch-millis (local tz). */
+export function fromDatetimeLocalValue(value: string): number {
+ // `new Date('YYYY-MM-DDTHH:mm')` is interpreted as local time.
+ const ms = new Date(value).getTime();
+ return Number.isNaN(ms) ? Date.now() : ms;
+}
diff --git a/js/app/packages/block-calendar/util/ics.ts b/js/app/packages/block-calendar/util/ics.ts
new file mode 100644
index 0000000000..fe68030d2c
--- /dev/null
+++ b/js/app/packages/block-calendar/util/ics.ts
@@ -0,0 +1,107 @@
+/**
+ * Minimal RFC-5545 iCalendar generation for event invites.
+ *
+ * The backend owns authoritative invite delivery, but we also generate a
+ * client-side `.ics` so the organizer can download/attach the invite and so the
+ * email body can embed a standards-compliant calendar component.
+ */
+import type { CalendarEvent } from '../model/types';
+
+function pad(n: number): string {
+ return n.toString().padStart(2, '0');
+}
+
+/** Format an epoch-ms instant as a UTC iCal timestamp (e.g. 20260612T143000Z). */
+export function toIcsUtc(ms: number): string {
+ const d = new Date(ms);
+ return (
+ `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
+ `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
+ );
+}
+
+/** Escape per RFC-5545 §3.3.11 (commas, semicolons, backslashes, newlines). */
+function escapeText(value: string): string {
+ return value
+ .replace(/\\/g, '\\\\')
+ .replace(/;/g, '\\;')
+ .replace(/,/g, '\\,')
+ .replace(/\r?\n/g, '\\n');
+}
+
+/** Fold long content lines to 75 octets per RFC-5545 §3.1. */
+function foldLine(line: string): string {
+ if (line.length <= 75) return line;
+ const chunks: string[] = [];
+ let rest = line;
+ chunks.push(rest.slice(0, 75));
+ rest = rest.slice(75);
+ while (rest.length > 0) {
+ chunks.push(` ${rest.slice(0, 74)}`);
+ rest = rest.slice(74);
+ }
+ return chunks.join('\r\n');
+}
+
+export interface IcsOptions {
+ organizerEmail: string;
+ organizerName?: string;
+ /** PUBLISH for informational, REQUEST when soliciting RSVPs. */
+ method?: 'REQUEST' | 'PUBLISH' | 'CANCEL';
+}
+
+export function buildIcs(event: CalendarEvent, opts: IcsOptions): string {
+ const method = opts.method ?? 'REQUEST';
+ const lines: string[] = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Macro//Calendar//EN',
+ 'CALSCALE:GREGORIAN',
+ `METHOD:${method}`,
+ 'BEGIN:VEVENT',
+ `UID:${event.id}@macro.com`,
+ `DTSTAMP:${toIcsUtc(Date.now())}`,
+ `DTSTART:${toIcsUtc(event.startMs)}`,
+ `DTEND:${toIcsUtc(event.endMs)}`,
+ `SUMMARY:${escapeText(event.title)}`,
+ ];
+
+ if (event.description) {
+ lines.push(`DESCRIPTION:${escapeText(event.description)}`);
+ }
+ if (event.location) {
+ lines.push(`LOCATION:${escapeText(event.location)}`);
+ }
+
+ const organizerCn = opts.organizerName ?? opts.organizerEmail;
+ lines.push(
+ `ORGANIZER;CN=${escapeText(organizerCn)}:mailto:${opts.organizerEmail}`
+ );
+
+ for (const attendee of event.attendees) {
+ const cn = escapeText(attendee.name ?? attendee.email);
+ lines.push(
+ `ATTENDEE;CN=${cn};ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:${attendee.email}`
+ );
+ }
+
+ lines.push('STATUS:CONFIRMED', 'END:VEVENT', 'END:VCALENDAR');
+
+ return lines.map(foldLine).join('\r\n');
+}
+
+/** Trigger a browser download of the event as an `.ics` file. */
+export function downloadIcs(event: CalendarEvent, opts: IcsOptions): void {
+ const ics = buildIcs(event, opts);
+ const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ const safeName =
+ event.title.replace(/[^a-z0-9]+/gi, '-').toLowerCase() || 'event';
+ anchor.download = `${safeName}.ics`;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(url);
+}
diff --git a/js/app/packages/block-calendar/util/invite.ts b/js/app/packages/block-calendar/util/invite.ts
new file mode 100644
index 0000000000..c3dbb9f914
--- /dev/null
+++ b/js/app/packages/block-calendar/util/invite.ts
@@ -0,0 +1,105 @@
+/**
+ * Sends event invites through the user's connected mailbox (email-service),
+ * so attendees receive a real email from the organizer. The backend separately
+ * records invite state via the calendar service.
+ */
+import { emailClient } from '@service-email/client';
+import { format } from 'date-fns';
+import type { CalendarEvent } from '../model/types';
+import { buildIcs } from './ics';
+
+/** Encode a UTF-8 string as base64 URL_SAFE_NO_PAD (the email API's body_html format). */
+function encodeBodyHtml(html: string): string {
+ return btoa(unescape(encodeURIComponent(html)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/={1,}$/, '');
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+}
+
+function whenLabel(event: CalendarEvent): string {
+ if (event.allDay) {
+ return format(event.startMs, 'EEEE, MMMM d, yyyy');
+ }
+ return `${format(event.startMs, 'EEEE, MMMM d, yyyy')} · ${format(
+ event.startMs,
+ 'h:mm a'
+ )} – ${format(event.endMs, 'h:mm a')}`;
+}
+
+function buildHtmlBody(
+ event: CalendarEvent,
+ organizerName: string,
+ ics: string
+): string {
+ const rows: string[] = [
+ `${escapeHtml(event.title || 'Untitled event')}
`,
+ `When: ${escapeHtml(whenLabel(event))}
`,
+ ];
+ if (event.location) {
+ rows.push(
+ `Where: ${escapeHtml(event.location)}
`
+ );
+ }
+ if (event.description) {
+ rows.push(
+ `${escapeHtml(event.description)}
`
+ );
+ }
+ rows.push(
+ `Invited by ${escapeHtml(organizerName)}.
`,
+ // Embed the iCalendar component so calendar-aware clients can import it.
+ `${escapeHtml(ics)}`
+ );
+ return `${rows.join('')}
`;
+}
+
+function buildTextBody(event: CalendarEvent, organizerName: string): string {
+ const lines = [event.title || 'Untitled event', `When: ${whenLabel(event)}`];
+ if (event.location) lines.push(`Where: ${event.location}`);
+ if (event.description) lines.push('', event.description);
+ lines.push('', `Invited by ${organizerName}.`);
+ return lines.join('\n');
+}
+
+export interface SendInviteArgs {
+ event: CalendarEvent;
+ organizerEmail: string;
+ organizerName: string;
+ /** Recipients to email; defaults to all attendees on the event. */
+ recipients?: { email: string; name?: string }[];
+}
+
+/**
+ * Sends the invite email. Returns the email-service result so callers can
+ * surface success/failure. Throws only on programmer error (no recipients).
+ */
+export async function sendInviteEmail(args: SendInviteArgs) {
+ const recipients =
+ args.recipients ??
+ args.event.attendees.map((a) => ({ email: a.email, name: a.name }));
+
+ const ics = buildIcs(args.event, {
+ organizerEmail: args.organizerEmail,
+ organizerName: args.organizerName,
+ method: 'REQUEST',
+ });
+
+ const html = buildHtmlBody(args.event, args.organizerName, ics);
+ const text = buildTextBody(args.event, args.organizerName);
+
+ return emailClient.sendMessage({
+ message: {
+ subject: `Invitation: ${args.event.title || 'Untitled event'}`,
+ to: recipients.map((r) => ({ email: r.email, name: r.name })),
+ body_html: encodeBodyHtml(html),
+ body_text: text,
+ },
+ });
+}
diff --git a/js/app/packages/core/constant/servers.ts b/js/app/packages/core/constant/servers.ts
index 3befe9eeb9..f75ffdb946 100644
--- a/js/app/packages/core/constant/servers.ts
+++ b/js/app/packages/core/constant/servers.ts
@@ -13,6 +13,7 @@ const serverHostLocal: Servers = {
'email-service': 'http://localhost:8087',
'image-proxy-service': 'http://localhost:8097',
'scheduled-action': 'http://localhost:8098',
+ 'calendar-service': 'http://localhost:8101',
} as const;
const devServerSuffix = import.meta.env.MODE === 'development' ? '-dev' : '';
@@ -37,6 +38,7 @@ const serverHostRemote = {
'email-service': `https://email-service${devServerSuffix}.macro.com`,
'image-proxy-service': `https://image-proxy${devServerSuffix}.macro.com`,
'scheduled-action': `https://agent-schedule${devServerSuffix}.macro.com`,
+ 'calendar-service': `https://calendar-service${devServerSuffix}.macro.com`,
} as const;
type Servers = Record;
diff --git a/js/app/packages/core/hotkey/tokens.ts b/js/app/packages/core/hotkey/tokens.ts
index 1d46008e93..fc7988c409 100644
--- a/js/app/packages/core/hotkey/tokens.ts
+++ b/js/app/packages/core/hotkey/tokens.ts
@@ -96,9 +96,21 @@ export const TOKENS = {
channels: 'sidebar.goTo.channels',
calls: 'sidebar.goTo.calls',
folders: 'sidebar.goTo.folders',
+ calendar: 'sidebar.goTo.calendar',
},
},
+ // calendar
+ calendar: {
+ prev: 'calendar.prev',
+ next: 'calendar.next',
+ today: 'calendar.today',
+ newEvent: 'calendar.newEvent',
+ viewDay: 'calendar.viewDay',
+ viewWeek: 'calendar.viewWeek',
+ viewList: 'calendar.viewList',
+ },
+
// email
email: {
nextThread: 'email.nextThread',
diff --git a/js/app/packages/icon/wide-calendar.tsx b/js/app/packages/icon/wide-calendar.tsx
new file mode 100644
index 0000000000..86432d4f27
--- /dev/null
+++ b/js/app/packages/icon/wide-calendar.tsx
@@ -0,0 +1,43 @@
+export const AnimatedCalendarIcon = (props: {
+ triggerAnimation?: boolean;
+ class?: string;
+}) => {
+ return (
+
+ );
+};
diff --git a/js/app/packages/queries/calendar/events.ts b/js/app/packages/queries/calendar/events.ts
new file mode 100644
index 0000000000..2ff29e310b
--- /dev/null
+++ b/js/app/packages/queries/calendar/events.ts
@@ -0,0 +1,124 @@
+import { throwOnErr } from '@core/util/result';
+import { queryClient } from '@queries/client';
+import { type MutationCallbacks, withCallbacks } from '@queries/utils';
+import { calendarClient } from '@service-calendar/client';
+import type {
+ CalendarEvent,
+ CreateEventRequest,
+ UpdateEventRequest,
+} from '@service-calendar/generated/schemas';
+import { useMutation, useQuery } from '@tanstack/solid-query';
+import type { Accessor } from 'solid-js';
+import { calendarKeys } from './keys';
+
+const QUERY_REFETCH_BEHAVIOR = {
+ refetchOnMount: 'always' as const,
+ refetchOnWindowFocus: 'always' as const,
+};
+
+export interface CalendarRange {
+ startMs: number;
+ endMs: number;
+}
+
+/** Reactive list of the user's events intersecting `range()`. */
+export function useCalendarEventsQuery(
+ range: Accessor,
+ enabled: Accessor
+) {
+ return useQuery(() => {
+ const { startMs, endMs } = range();
+ return {
+ queryKey: calendarKeys.range({ startMs, endMs }).queryKey,
+ enabled: enabled(),
+ queryFn: async () =>
+ throwOnErr(
+ async () => await calendarClient.listEvents({ startMs, endMs })
+ ),
+ placeholderData: (prev: CalendarEvent[] | undefined) => prev,
+ reconcile: 'id',
+ ...QUERY_REFETCH_BEHAVIOR,
+ };
+ });
+}
+
+/** Invalidates every cached calendar range so views refetch after a write. */
+export function invalidateCalendar() {
+ return queryClient.invalidateQueries({ queryKey: calendarKeys._def });
+}
+
+export function useCreateEventMutation(
+ callbacks?: MutationCallbacks
+) {
+ return useMutation(() => ({
+ mutationFn: async (request: CreateEventRequest) =>
+ throwOnErr(async () => await calendarClient.createEvent(request)),
+ ...withCallbacks(
+ {
+ onSuccess: async () => {
+ await invalidateCalendar();
+ },
+ },
+ callbacks
+ ),
+ }));
+}
+
+export function useUpdateEventMutation(
+ callbacks?: MutationCallbacks<
+ CalendarEvent,
+ Error,
+ { id: string; body: UpdateEventRequest }
+ >
+) {
+ return useMutation(() => ({
+ mutationFn: async (args: { id: string; body: UpdateEventRequest }) =>
+ throwOnErr(async () => await calendarClient.updateEvent(args)),
+ ...withCallbacks(
+ {
+ onSuccess: async () => {
+ await invalidateCalendar();
+ },
+ },
+ callbacks
+ ),
+ }));
+}
+
+export function useDeleteEventMutation(
+ callbacks?: MutationCallbacks<{ success: boolean }, Error, { id: string }>
+) {
+ return useMutation(() => ({
+ mutationFn: async (args: { id: string }) =>
+ throwOnErr(async () => await calendarClient.deleteEvent(args)),
+ ...withCallbacks(
+ {
+ onSuccess: async () => {
+ await invalidateCalendar();
+ },
+ },
+ callbacks
+ ),
+ }));
+}
+
+export function useInviteAttendeesMutation(
+ callbacks?: MutationCallbacks<
+ CalendarEvent,
+ Error,
+ { id: string; emails: string[] }
+ >
+) {
+ return useMutation(() => ({
+ mutationFn: async (args: { id: string; emails: string[] }) =>
+ throwOnErr(async () => await calendarClient.inviteAttendees(args)),
+ ...withCallbacks(
+ {
+ onSuccess: async () => {
+ await invalidateCalendar();
+ },
+ },
+ callbacks
+ ),
+ }));
+}
diff --git a/js/app/packages/queries/calendar/keys.ts b/js/app/packages/queries/calendar/keys.ts
new file mode 100644
index 0000000000..e6cc9c04c4
--- /dev/null
+++ b/js/app/packages/queries/calendar/keys.ts
@@ -0,0 +1,8 @@
+import { createQueryKeys } from '@lukemorales/query-key-factory';
+
+export const calendarKeys = createQueryKeys('calendar', {
+ all: null,
+ range: (params: { startMs: number; endMs: number }) => ({
+ queryKey: [params.startMs, params.endMs],
+ }),
+});
diff --git a/js/app/packages/service-clients/orval.config.ts b/js/app/packages/service-clients/orval.config.ts
index a69e8812b7..a3a6f04258 100644
--- a/js/app/packages/service-clients/orval.config.ts
+++ b/js/app/packages/service-clients/orval.config.ts
@@ -125,6 +125,19 @@ export default defineConfig({
target: './service-scheduled-action/openapi.json',
},
},
+ calendarService: {
+ output: {
+ client: 'fetch',
+ target: './service-calendar/generated/client.ts',
+ schemas: './service-calendar/generated/schemas',
+ override: {
+ useDates: false,
+ },
+ },
+ input: {
+ target: './service-calendar/openapi.json',
+ },
+ },
searchService: {
output: {
client: 'fetch',
diff --git a/js/app/packages/service-clients/service-calendar/client.ts b/js/app/packages/service-clients/service-calendar/client.ts
new file mode 100644
index 0000000000..d0f308b4a2
--- /dev/null
+++ b/js/app/packages/service-clients/service-calendar/client.ts
@@ -0,0 +1,71 @@
+import { SERVER_HOSTS } from '@core/constant/servers';
+import {
+ type FetchWithTokenErrorCode,
+ fetchWithToken,
+} from '@core/util/fetchWithToken';
+import type { ObjectLike, ResultError } from '@core/util/result';
+import type { SafeFetchInit } from '@core/util/safeFetch';
+import type { Result } from 'neverthrow';
+import type {
+ CalendarEvent,
+ CreateEventRequest,
+ UpdateEventRequest,
+} from './generated/schemas';
+
+const calendarHost: string = SERVER_HOSTS['calendar-service'];
+
+function calendarFetch(
+ url: string,
+ init?: SafeFetchInit
+): Promise[]>>;
+function calendarFetch(
+ url: string,
+ init?: SafeFetchInit
+): Promise[]>>;
+function calendarFetch(
+ url: string,
+ init?: SafeFetchInit
+):
+ | Promise[]>>
+ | Promise[]>> {
+ return fetchWithToken(`${calendarHost}${url}`, init);
+}
+
+export const calendarClient = {
+ listEvents: async (args: { startMs: number; endMs: number }) =>
+ calendarFetch(
+ `/calendar/events?start_ms=${args.startMs}&end_ms=${args.endMs}`,
+ { method: 'GET' }
+ ),
+
+ getEvent: async (args: { id: string }) =>
+ calendarFetch(`/calendar/events/${args.id}`, {
+ method: 'GET',
+ }),
+
+ createEvent: async (body: CreateEventRequest) =>
+ calendarFetch('/calendar/events', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ }),
+
+ updateEvent: async (args: { id: string; body: UpdateEventRequest }) =>
+ calendarFetch(`/calendar/events/${args.id}`, {
+ method: 'PUT',
+ body: JSON.stringify(args.body),
+ }),
+
+ deleteEvent: async (args: { id: string }) => {
+ const result = await calendarFetch>(
+ `/calendar/events/${args.id}`,
+ { method: 'DELETE' }
+ );
+ return result.map(() => ({ success: true }));
+ },
+
+ inviteAttendees: async (args: { id: string; emails: string[] }) =>
+ calendarFetch(`/calendar/events/${args.id}/invite`, {
+ method: 'POST',
+ body: JSON.stringify({ emails: args.emails }),
+ }),
+};
diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/attendee.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/attendee.ts
new file mode 100644
index 0000000000..47d7a81e25
--- /dev/null
+++ b/js/app/packages/service-clients/service-calendar/generated/schemas/attendee.ts
@@ -0,0 +1,17 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * calendar
+ * OpenAPI spec version: 0.1.0
+ */
+
+export interface Attendee {
+ /** Attendee email address. */
+ email: string;
+ /** Optional display name. */
+ name: string | null;
+ /** RSVP status: pending | accepted | declined | tentative. */
+ status: string;
+ /** Epoch-millis the invite was sent, if it has been. */
+ invited_ms: number | null;
+}
diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/attendeeInput.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/attendeeInput.ts
new file mode 100644
index 0000000000..479be2eddb
--- /dev/null
+++ b/js/app/packages/service-clients/service-calendar/generated/schemas/attendeeInput.ts
@@ -0,0 +1,13 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * calendar
+ * OpenAPI spec version: 0.1.0
+ */
+
+export interface AttendeeInput {
+ /** Attendee email address. */
+ email: string;
+ /** Optional display name. */
+ name?: string | null;
+}
diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/calendarEvent.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/calendarEvent.ts
new file mode 100644
index 0000000000..669565f203
--- /dev/null
+++ b/js/app/packages/service-clients/service-calendar/generated/schemas/calendarEvent.ts
@@ -0,0 +1,28 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * calendar
+ * OpenAPI spec version: 0.1.0
+ */
+import type { Attendee } from './attendee';
+
+export interface CalendarEvent {
+ /** Event id (UUID, serialized as a string). */
+ id: string;
+ /** Event title. */
+ title: string;
+ /** Optional long-form description. */
+ description: string | null;
+ /** Optional location / conferencing link. */
+ location: string | null;
+ /** Start instant (epoch-millis, UTC). */
+ start_ms: number;
+ /** End instant (epoch-millis, UTC). */
+ end_ms: number;
+ /** Whether this is an all-day event. */
+ all_day: boolean;
+ /** Accent color key (e.g. blue, green). */
+ color: string;
+ /** Invited attendees. */
+ attendees: Attendee[];
+}
diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/createEventRequest.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/createEventRequest.ts
new file mode 100644
index 0000000000..b0372e3ad1
--- /dev/null
+++ b/js/app/packages/service-clients/service-calendar/generated/schemas/createEventRequest.ts
@@ -0,0 +1,26 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * calendar
+ * OpenAPI spec version: 0.1.0
+ */
+import type { AttendeeInput } from './attendeeInput';
+
+export interface CreateEventRequest {
+ /** Event title. */
+ title: string;
+ /** Optional description. */
+ description?: string | null;
+ /** Optional location. */
+ location?: string | null;
+ /** Start instant (epoch-millis). */
+ start_ms: number;
+ /** End instant (epoch-millis). */
+ end_ms: number;
+ /** All-day flag. */
+ all_day?: boolean;
+ /** Accent color key; defaults to `blue` when omitted. */
+ color?: string;
+ /** Attendees to invite. */
+ attendees?: AttendeeInput[];
+}
diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/index.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/index.ts
new file mode 100644
index 0000000000..e98b7916d9
--- /dev/null
+++ b/js/app/packages/service-clients/service-calendar/generated/schemas/index.ts
@@ -0,0 +1,13 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * calendar
+ * OpenAPI spec version: 0.1.0
+ */
+
+export * from './attendee';
+export * from './attendeeInput';
+export * from './calendarEvent';
+export * from './createEventRequest';
+export * from './inviteRequest';
+export * from './updateEventRequest';
diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/inviteRequest.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/inviteRequest.ts
new file mode 100644
index 0000000000..474567e4c2
--- /dev/null
+++ b/js/app/packages/service-clients/service-calendar/generated/schemas/inviteRequest.ts
@@ -0,0 +1,11 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * calendar
+ * OpenAPI spec version: 0.1.0
+ */
+
+export interface InviteRequest {
+ /** Attendee emails that have just been (or are being) emailed an invite. */
+ emails: string[];
+}
diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/updateEventRequest.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/updateEventRequest.ts
new file mode 100644
index 0000000000..cd1a97de2c
--- /dev/null
+++ b/js/app/packages/service-clients/service-calendar/generated/schemas/updateEventRequest.ts
@@ -0,0 +1,10 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * calendar
+ * OpenAPI spec version: 0.1.0
+ */
+import type { CreateEventRequest } from './createEventRequest';
+
+/** Body for PUT /calendar/events/{id}. Same shape as create. */
+export type UpdateEventRequest = CreateEventRequest;
diff --git a/js/app/scripts/generate-api-schema.ts b/js/app/scripts/generate-api-schema.ts
index 1eca22d37f..b3235b4cc6 100644
--- a/js/app/scripts/generate-api-schema.ts
+++ b/js/app/scripts/generate-api-schema.ts
@@ -27,6 +27,7 @@ const serviceToCrate: Record = {
"email-service": "email_service",
"search-service": "search_service",
"scheduled-action": "scheduled_action",
+ calendar: "calendar_service",
};
const getRustCloudStorageDir = () =>
diff --git a/js/app/scripts/services.ts b/js/app/scripts/services.ts
index 3ce8f9b321..c76cc9651d 100644
--- a/js/app/scripts/services.ts
+++ b/js/app/scripts/services.ts
@@ -104,6 +104,14 @@ export const services: Service[] = [
output: "../packages/service-clients/service-scheduled-action/",
orvalKey: "scheduledActionService",
},
+ {
+ name: "calendar",
+ dev: "https://calendar-service-dev.macro.com/api-doc/openapi.json",
+ prod: "https://calendar-service.macro.com/api-doc/openapi.json",
+ local: "http://localhost:8101/api-doc/openapi.json",
+ output: "../packages/service-clients/service-calendar/",
+ orvalKey: "calendarService",
+ },
];
export const documentCognitionBase: Service = {
diff --git a/js/app/tsconfig.json b/js/app/tsconfig.json
index 390b24d108..ca0516a32f 100644
--- a/js/app/tsconfig.json
+++ b/js/app/tsconfig.json
@@ -111,6 +111,9 @@
"@service-scheduled-action/*": [
"./packages/service-clients/service-scheduled-action/*"
],
+ "@service-calendar/*": [
+ "./packages/service-clients/service-calendar/*"
+ ],
"@service-email/*": [
"./packages/service-clients/service-email/*"
],
@@ -150,6 +153,12 @@
"@block-unknown/*": [
"./packages/block-unknown/*"
],
+ "@block-calendar": [
+ "./packages/block-calendar/index.ts"
+ ],
+ "@block-calendar/*": [
+ "./packages/block-calendar/*"
+ ],
"@block-video/*": [
"./packages/block-video/*"
],
diff --git a/rust/cloud-storage/Cargo.lock b/rust/cloud-storage/Cargo.lock
index 4b26d299a3..d21a440a55 100644
--- a/rust/cloud-storage/Cargo.lock
+++ b/rust/cloud-storage/Cargo.lock
@@ -1892,6 +1892,49 @@ dependencies = [
"utoipa",
]
+[[package]]
+name = "calendar"
+version = "0.1.0"
+dependencies = [
+ "axum",
+ "axum-extra",
+ "macro_auth",
+ "macro_middleware",
+ "macro_user_id",
+ "model_user",
+ "rootcause",
+ "serde",
+ "serde_json",
+ "sqlx",
+ "tokio",
+ "tracing",
+ "utoipa",
+]
+
+[[package]]
+name = "calendar_service"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "aws-sdk-secretsmanager",
+ "axum",
+ "calendar",
+ "http-body-util",
+ "macro_auth",
+ "macro_aws_config",
+ "macro_cors",
+ "macro_entrypoint",
+ "macro_env",
+ "macro_middleware",
+ "secretsmanager_client",
+ "sqlx",
+ "tokio",
+ "tower 0.5.3",
+ "tracing",
+ "utoipa",
+ "utoipa-swagger-ui",
+]
+
[[package]]
name = "call"
version = "0.1.0"
diff --git a/rust/cloud-storage/Cargo.toml b/rust/cloud-storage/Cargo.toml
index 2a16e91730..d91a1eb762 100644
--- a/rust/cloud-storage/Cargo.toml
+++ b/rust/cloud-storage/Cargo.toml
@@ -5,6 +5,7 @@ members = [
"agent",
"authentication_service",
"backfill_entity_access",
+ "calendar_service",
"bot_id",
"bots",
"call_recording_preview_handler",
diff --git a/rust/cloud-storage/calendar/Cargo.toml b/rust/cloud-storage/calendar/Cargo.toml
new file mode 100644
index 0000000000..9749f039bb
--- /dev/null
+++ b/rust/cloud-storage/calendar/Cargo.toml
@@ -0,0 +1,36 @@
+[package]
+edition = "2024"
+name = "calendar"
+publish = false
+version = "0.1.0"
+
+[features]
+axum = [
+ "dep:axum",
+ "dep:axum-extra",
+ "dep:macro_auth",
+ "dep:macro_middleware",
+ "dep:model_user",
+ "dep:utoipa",
+]
+default = ["axum", "inbound", "outbound", "ports"]
+inbound = ["axum", "ports"]
+outbound = ["dep:sqlx", "ports"]
+ports = []
+
+[dependencies]
+axum = { workspace = true, optional = true }
+axum-extra = { workspace = true, optional = true }
+macro_auth = { path = "../macro_auth", optional = true }
+macro_middleware = { path = "../macro_middleware", default-features = false, features = [
+ "auth",
+], optional = true }
+macro_user_id = { path = "../macro_user_id" }
+model_user = { path = "../model_user", features = ["axum"], optional = true }
+rootcause = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+sqlx = { workspace = true, optional = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+utoipa = { workspace = true, optional = true }
diff --git a/rust/cloud-storage/calendar/src/domain.rs b/rust/cloud-storage/calendar/src/domain.rs
new file mode 100644
index 0000000000..2ed1d5dafb
--- /dev/null
+++ b/rust/cloud-storage/calendar/src/domain.rs
@@ -0,0 +1,6 @@
+/// Wire/domain models shared across the HTTP and persistence layers.
+pub mod models;
+/// Port traits defining the repository and service boundaries.
+pub mod ports;
+/// The calendar domain service (business logic).
+pub mod service;
diff --git a/rust/cloud-storage/calendar/src/domain/models.rs b/rust/cloud-storage/calendar/src/domain/models.rs
new file mode 100644
index 0000000000..d655dbeba4
--- /dev/null
+++ b/rust/cloud-storage/calendar/src/domain/models.rs
@@ -0,0 +1,97 @@
+//! Request/response models for the calendar API.
+//!
+//! Instants are epoch-millis (`i64`) to match the frontend and the DB columns.
+
+use serde::{Deserialize, Serialize};
+
+#[cfg(feature = "axum")]
+use utoipa::ToSchema;
+
+/// An invited attendee on an event.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[cfg_attr(feature = "axum", derive(ToSchema))]
+pub struct Attendee {
+ /// Attendee email address.
+ pub email: String,
+ /// Optional display name.
+ pub name: Option,
+ /// RSVP status: `pending` | `accepted` | `declined` | `tentative`.
+ pub status: String,
+ /// Epoch-millis the invite was sent, if it has been.
+ pub invited_ms: Option,
+}
+
+/// A calendar event owned by the requesting user.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[cfg_attr(feature = "axum", derive(ToSchema))]
+pub struct CalendarEvent {
+ /// Event id (UUID, serialized as a string).
+ pub id: String,
+ /// Event title.
+ pub title: String,
+ /// Optional long-form description.
+ pub description: Option,
+ /// Optional location / conferencing link.
+ pub location: Option,
+ /// Start instant (epoch-millis, UTC).
+ pub start_ms: i64,
+ /// End instant (epoch-millis, UTC).
+ pub end_ms: i64,
+ /// Whether this is an all-day event.
+ pub all_day: bool,
+ /// Accent color key (e.g. `blue`, `green`).
+ pub color: String,
+ /// Invited attendees.
+ pub attendees: Vec,
+}
+
+/// An attendee supplied when creating/updating an event.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[cfg_attr(feature = "axum", derive(ToSchema))]
+pub struct AttendeeInput {
+ /// Attendee email address.
+ pub email: String,
+ /// Optional display name.
+ pub name: Option,
+}
+
+/// Body for `POST /calendar/events`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[cfg_attr(feature = "axum", derive(ToSchema))]
+pub struct CreateEventRequest {
+ /// Event title.
+ pub title: String,
+ /// Optional description.
+ pub description: Option,
+ /// Optional location.
+ pub location: Option,
+ /// Start instant (epoch-millis).
+ pub start_ms: i64,
+ /// End instant (epoch-millis).
+ pub end_ms: i64,
+ /// All-day flag.
+ #[serde(default)]
+ pub all_day: bool,
+ /// Accent color key; defaults to `blue` when omitted.
+ #[serde(default = "default_color")]
+ pub color: String,
+ /// Attendees to invite.
+ #[serde(default)]
+ pub attendees: Vec,
+}
+
+/// Body for `PUT /calendar/events/{id}`. Same shape as create.
+pub type UpdateEventRequest = CreateEventRequest;
+
+/// Body for `POST /calendar/events/{id}/invite`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[cfg_attr(feature = "axum", derive(ToSchema))]
+pub struct InviteRequest {
+ /// Attendee emails that have just been (or are being) emailed an invite.
+ /// Any not already on the event are added.
+ pub emails: Vec,
+}
+
+fn default_color() -> String {
+ "blue".to_string()
+}
diff --git a/rust/cloud-storage/calendar/src/domain/ports.rs b/rust/cloud-storage/calendar/src/domain/ports.rs
new file mode 100644
index 0000000000..6f323d205d
--- /dev/null
+++ b/rust/cloud-storage/calendar/src/domain/ports.rs
@@ -0,0 +1,101 @@
+//! Port traits separating the domain from infrastructure.
+
+use crate::domain::models::{CalendarEvent, CreateEventRequest, UpdateEventRequest};
+use rootcause::Report;
+
+/// Persistence boundary for calendar events and their attendees.
+pub trait CalendarRepository: Send + Sync + 'static {
+ /// Lists a user's events whose span intersects `[start_ms, end_ms)`.
+ fn list_events(
+ &self,
+ user_id: &str,
+ start_ms: i64,
+ end_ms: i64,
+ ) -> impl Future