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 ( + { + if (!open) close(); + }} + position="center" + class="rounded-md border border-edge-muted bg-surface shadow-lg" + > + + {(currentDraft) => ( +
+
+ + {currentDraft().id ? 'Edit event' : 'New event'} + + +
+ +
+ update({ title: e.currentTarget.value })} + /> + + + +
+ + +
+ + update({ location: e.currentTarget.value })} + /> + +