@startsimpli/ui 0.4.13 → 0.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -66,6 +66,7 @@
66
66
  "date-fns": "^3.6.0",
67
67
  "dompurify": "^3.4.1",
68
68
  "lucide-react": "^0.408.0",
69
+ "react-big-calendar": "^1.19.4",
69
70
  "react-day-picker": "^9.14.0",
70
71
  "tailwind-merge": "^2.6.1",
71
72
  "tailwindcss-animate": "^1.0.7",
@@ -81,6 +82,7 @@
81
82
  "@types/jest": "^30.0.0",
82
83
  "@types/node": "^20.19.39",
83
84
  "@types/react": "^19.2.14",
85
+ "@types/react-big-calendar": "^1.16.3",
84
86
  "@types/react-dom": "^19.2.3",
85
87
  "eslint": "^8.57.1",
86
88
  "identity-obj-proxy": "^3.0.0",
@@ -0,0 +1,97 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { CalendarView, type CalendarEvent } from '../calendar/calendar-view'
3
+
4
+ const FIXED_DATE = new Date('2026-04-15T12:00:00Z')
5
+
6
+ const sampleEvents: CalendarEvent[] = [
7
+ {
8
+ id: 'meet-1',
9
+ title: 'Investor Sync — Sequoia',
10
+ start: new Date('2026-04-15T16:00:00Z'),
11
+ end: new Date('2026-04-15T16:30:00Z'),
12
+ },
13
+ {
14
+ id: 'meet-2',
15
+ title: 'Demo with Acme',
16
+ start: '2026-04-16T17:00:00Z',
17
+ end: '2026-04-16T17:45:00Z',
18
+ },
19
+ ]
20
+
21
+ describe('CalendarView', () => {
22
+ it('renders without crashing in month view', () => {
23
+ render(
24
+ <CalendarView
25
+ events={sampleEvents}
26
+ defaultDate={FIXED_DATE}
27
+ defaultView="month"
28
+ />
29
+ )
30
+ // The toolbar's "Today" button is always rendered by react-big-calendar
31
+ expect(screen.getByText('Today')).toBeInTheDocument()
32
+ })
33
+
34
+ it('renders event titles on the grid', () => {
35
+ render(
36
+ <CalendarView
37
+ events={sampleEvents}
38
+ defaultDate={FIXED_DATE}
39
+ defaultView="month"
40
+ />
41
+ )
42
+ expect(screen.getByText(/Investor Sync — Sequoia/)).toBeInTheDocument()
43
+ expect(screen.getByText(/Demo with Acme/)).toBeInTheDocument()
44
+ })
45
+
46
+ it('fires onEventClick with the original event when an event is clicked', () => {
47
+ const onEventClick = jest.fn()
48
+ render(
49
+ <CalendarView
50
+ events={sampleEvents}
51
+ defaultDate={FIXED_DATE}
52
+ defaultView="month"
53
+ onEventClick={onEventClick}
54
+ />
55
+ )
56
+ fireEvent.click(screen.getByText(/Investor Sync — Sequoia/))
57
+ expect(onEventClick).toHaveBeenCalledTimes(1)
58
+ expect(onEventClick.mock.calls[0][0].id).toBe('meet-1')
59
+ expect(onEventClick.mock.calls[0][0].title).toBe('Investor Sync — Sequoia')
60
+ })
61
+
62
+ it('accepts string dates in events and normalizes them', () => {
63
+ // Verifies the toDate / normalizeEvent path
64
+ render(
65
+ <CalendarView
66
+ events={[
67
+ { id: 'x', title: 'Stringy', start: '2026-04-15T16:00:00Z' },
68
+ ]}
69
+ defaultDate={FIXED_DATE}
70
+ defaultView="month"
71
+ />
72
+ )
73
+ expect(screen.getByText(/Stringy/)).toBeInTheDocument()
74
+ })
75
+
76
+ it('passes through resource payload to onEventClick', () => {
77
+ interface MyMeeting {
78
+ uuid: string
79
+ attendees: number
80
+ }
81
+ const meeting: MyMeeting = { uuid: 'abc-123', attendees: 5 }
82
+ const onEventClick = jest.fn()
83
+ render(
84
+ <CalendarView<MyMeeting>
85
+ events={[
86
+ { id: 'r1', title: 'WithResource', start: FIXED_DATE, resource: meeting },
87
+ ]}
88
+ defaultDate={FIXED_DATE}
89
+ defaultView="month"
90
+ onEventClick={onEventClick}
91
+ />
92
+ )
93
+ fireEvent.click(screen.getByText(/WithResource/))
94
+ expect(onEventClick).toHaveBeenCalledTimes(1)
95
+ expect(onEventClick.mock.calls[0][0].resource).toEqual(meeting)
96
+ })
97
+ })
@@ -0,0 +1,104 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { UpcomingMeetings, type UpcomingMeeting } from '../calendar/upcoming-meetings'
3
+
4
+ const sampleMeetings: UpcomingMeeting[] = [
5
+ {
6
+ id: 1,
7
+ title: 'Sequoia partner sync',
8
+ scheduled_at: '2026-04-29T16:00:00Z',
9
+ status: 'confirmed',
10
+ investor_name: 'Roelof Botha',
11
+ investor_email: 'roelof@sequoia.com',
12
+ meeting_link: 'https://meet.google.com/abc',
13
+ },
14
+ {
15
+ id: 2,
16
+ title: 'Acme partner intro',
17
+ scheduled_at: '2026-04-30T14:00:00Z',
18
+ status: 'pending',
19
+ investor_email: 'partner@acme.vc',
20
+ },
21
+ {
22
+ id: 3,
23
+ title: 'Cancelled call',
24
+ scheduled_at: '2026-05-01T10:00:00Z',
25
+ status: 'cancelled',
26
+ },
27
+ ]
28
+
29
+ describe('UpcomingMeetings', () => {
30
+ it('renders the title and each meeting title', () => {
31
+ render(<UpcomingMeetings meetings={sampleMeetings} />)
32
+ expect(screen.getByText('Upcoming Meetings')).toBeInTheDocument()
33
+ expect(screen.getByText('Sequoia partner sync')).toBeInTheDocument()
34
+ expect(screen.getByText('Acme partner intro')).toBeInTheDocument()
35
+ expect(screen.getByText('Cancelled call')).toBeInTheDocument()
36
+ })
37
+
38
+ it('shows the right status badge per meeting', () => {
39
+ render(<UpcomingMeetings meetings={sampleMeetings} />)
40
+ expect(screen.getByText('Confirmed')).toBeInTheDocument()
41
+ expect(screen.getByText('Pending')).toBeInTheDocument()
42
+ expect(screen.getByText('Cancelled')).toBeInTheDocument()
43
+ })
44
+
45
+ it('renders skeletons in loading state', () => {
46
+ const { container } = render(
47
+ <UpcomingMeetings meetings={[]} loading loadingRowCount={5} />
48
+ )
49
+ // Skeletons render as empty divs with animate-pulse — count them by class.
50
+ const skeletons = container.querySelectorAll('.animate-pulse')
51
+ expect(skeletons).toHaveLength(5)
52
+ })
53
+
54
+ it('renders empty state when no meetings', () => {
55
+ render(<UpcomingMeetings meetings={[]} />)
56
+ expect(screen.getByText('No upcoming meetings')).toBeInTheDocument()
57
+ })
58
+
59
+ it('does not render Schedule CTA when onSchedule is not provided', () => {
60
+ render(<UpcomingMeetings meetings={[]} />)
61
+ expect(screen.queryByText('Schedule one')).not.toBeInTheDocument()
62
+ })
63
+
64
+ it('renders Schedule CTA when onSchedule is provided and fires it on click', () => {
65
+ const onSchedule = jest.fn()
66
+ render(<UpcomingMeetings meetings={[]} onSchedule={onSchedule} />)
67
+ const cta = screen.getByText('Schedule one')
68
+ expect(cta).toBeInTheDocument()
69
+ fireEvent.click(cta)
70
+ expect(onSchedule).toHaveBeenCalledTimes(1)
71
+ })
72
+
73
+ it('fires onMeetingClick with the original meeting payload', () => {
74
+ const onMeetingClick = jest.fn()
75
+ render(<UpcomingMeetings meetings={sampleMeetings} onMeetingClick={onMeetingClick} />)
76
+ fireEvent.click(screen.getByText('Sequoia partner sync'))
77
+ expect(onMeetingClick).toHaveBeenCalledTimes(1)
78
+ expect(onMeetingClick.mock.calls[0][0].id).toBe(1)
79
+ expect(onMeetingClick.mock.calls[0][0].investor_name).toBe('Roelof Botha')
80
+ })
81
+
82
+ it('renders View all link when viewAllHref is provided', () => {
83
+ render(<UpcomingMeetings meetings={sampleMeetings} viewAllHref="/calendar" />)
84
+ const link = screen.getByText('View all')
85
+ expect(link.closest('a')).toHaveAttribute('href', '/calendar')
86
+ })
87
+
88
+ it('renders Join link when meeting_link is set', () => {
89
+ render(<UpcomingMeetings meetings={sampleMeetings} />)
90
+ const join = screen.getByText('Join')
91
+ expect(join.closest('a')).toHaveAttribute('href', 'https://meet.google.com/abc')
92
+ })
93
+
94
+ it("falls back to investor_email when investor_name is absent", () => {
95
+ render(<UpcomingMeetings meetings={sampleMeetings} />)
96
+ expect(screen.getByText('partner@acme.vc')).toBeInTheDocument()
97
+ })
98
+
99
+ it('uses a custom title when title prop is provided', () => {
100
+ render(<UpcomingMeetings meetings={[]} title="This Week's Calls" />)
101
+ expect(screen.getByText("This Week's Calls")).toBeInTheDocument()
102
+ expect(screen.queryByText('Upcoming Meetings')).not.toBeInTheDocument()
103
+ })
104
+ })
@@ -0,0 +1,222 @@
1
+ "use client"
2
+
3
+ /**
4
+ * CalendarView — shared month/week/day event grid.
5
+ *
6
+ * Wraps react-big-calendar with a date-fns localizer pre-wired and a
7
+ * simplified event shape so consumers don't have to reach for raw RBC types.
8
+ *
9
+ * IMPORTANT: react-big-calendar ships its own CSS. Consumers must import
10
+ * `react-big-calendar/lib/css/react-big-calendar.css` once at the app level
11
+ * (e.g. in _app.tsx / layout.tsx). This package does NOT auto-inject CSS so
12
+ * consumers can layer Tailwind overrides predictably.
13
+ *
14
+ * lifecycle:calendar-ui (raise-simpli-k4u)
15
+ */
16
+
17
+ import * as React from "react"
18
+ import {
19
+ Calendar,
20
+ dateFnsLocalizer,
21
+ Views,
22
+ type View,
23
+ type SlotInfo,
24
+ } from "react-big-calendar"
25
+ import { format, parse, startOfWeek, getDay } from "date-fns"
26
+ import { enUS } from "date-fns/locale"
27
+
28
+ const locales = { "en-US": enUS }
29
+
30
+ const localizer = dateFnsLocalizer({
31
+ format,
32
+ parse,
33
+ startOfWeek: () => startOfWeek(new Date(), { weekStartsOn: 0 }),
34
+ getDay,
35
+ locales,
36
+ })
37
+
38
+ /** Domain-friendly event shape callers pass in. */
39
+ export interface CalendarEvent<T = unknown> {
40
+ /** Stable identifier — passed through to onEventClick payload */
41
+ id: string | number
42
+ /** Event title shown on the grid */
43
+ title: string
44
+ /** Event start (Date or ISO string) */
45
+ start: Date | string
46
+ /** Event end. If omitted, defaults to start + 30min */
47
+ end?: Date | string
48
+ /** Whether to render as all-day across the top of the day */
49
+ allDay?: boolean
50
+ /** Optional pass-through payload (the original ScheduledMeeting / etc.) */
51
+ resource?: T
52
+ }
53
+
54
+ interface InternalRBCEvent {
55
+ id: string | number
56
+ title: string
57
+ start: Date
58
+ end: Date
59
+ allDay: boolean
60
+ resource: unknown
61
+ }
62
+
63
+ const DEFAULT_DURATION_MS = 30 * 60 * 1000
64
+
65
+ function toDate(value: Date | string): Date {
66
+ return value instanceof Date ? value : new Date(value)
67
+ }
68
+
69
+ function normalizeEvent(e: CalendarEvent): InternalRBCEvent {
70
+ const start = toDate(e.start)
71
+ const end = e.end ? toDate(e.end) : new Date(start.getTime() + DEFAULT_DURATION_MS)
72
+ return {
73
+ id: e.id,
74
+ title: e.title,
75
+ start,
76
+ end,
77
+ allDay: e.allDay ?? false,
78
+ resource: e.resource,
79
+ }
80
+ }
81
+
82
+ export type CalendarViewMode = "month" | "week" | "day" | "agenda"
83
+
84
+ const VIEW_BY_MODE: Record<CalendarViewMode, View> = {
85
+ month: Views.MONTH,
86
+ week: Views.WEEK,
87
+ day: Views.DAY,
88
+ agenda: Views.AGENDA,
89
+ }
90
+
91
+ export interface CalendarViewProps<T = unknown> {
92
+ /** Events to render on the grid */
93
+ events: CalendarEvent<T>[]
94
+ /** Currently navigated date (controlled) */
95
+ date?: Date
96
+ /** Default date if uncontrolled */
97
+ defaultDate?: Date
98
+ /** Active view (controlled) */
99
+ view?: CalendarViewMode
100
+ /** Default view if uncontrolled */
101
+ defaultView?: CalendarViewMode
102
+ /** Allowed views the toolbar can switch between */
103
+ views?: CalendarViewMode[]
104
+ /** Fires when the user clicks an event. Receives the original event passed in. */
105
+ onEventClick?: (event: CalendarEvent<T>) => void
106
+ /** Fires when the user clicks an empty slot (or drags to select a range). */
107
+ onSlotClick?: (slot: { start: Date; end: Date; action: "click" | "select" }) => void
108
+ /** Fires when the user navigates to a new date. */
109
+ onNavigate?: (date: Date) => void
110
+ /** Fires when the user changes view. */
111
+ onViewChange?: (view: CalendarViewMode) => void
112
+ /** Allow drag-to-select empty time slots (defaults true). */
113
+ selectable?: boolean
114
+ /** Min height for the grid (default 600px). */
115
+ minHeight?: number
116
+ /** className passed to the wrapping div for Tailwind overrides. */
117
+ className?: string
118
+ }
119
+
120
+ /**
121
+ * Shared calendar grid. Pre-wired with date-fns localizer.
122
+ *
123
+ * @example
124
+ * import "react-big-calendar/lib/css/react-big-calendar.css"
125
+ * import { CalendarView } from "@startsimpli/ui"
126
+ *
127
+ * <CalendarView
128
+ * events={meetings.map(m => ({
129
+ * id: m.id,
130
+ * title: m.title,
131
+ * start: m.scheduled_at,
132
+ * end: addMinutes(new Date(m.scheduled_at), m.duration_minutes),
133
+ * resource: m,
134
+ * }))}
135
+ * onEventClick={e => openMeetingDialog(e.resource)}
136
+ * onSlotClick={s => openCreateMeetingDialog(s.start)}
137
+ * />
138
+ */
139
+ export function CalendarView<T = unknown>({
140
+ events,
141
+ date,
142
+ defaultDate,
143
+ view,
144
+ defaultView,
145
+ views,
146
+ onEventClick,
147
+ onSlotClick,
148
+ onNavigate,
149
+ onViewChange,
150
+ selectable = true,
151
+ minHeight = 600,
152
+ className,
153
+ }: CalendarViewProps<T>) {
154
+ const normalized = React.useMemo(() => events.map(normalizeEvent), [events])
155
+
156
+ // Map our public CalendarEvent[] back from the internal event RBC hands us
157
+ // on click — keeps the consumer API clean (they only see what they passed).
158
+ const eventByInternalId = React.useMemo(() => {
159
+ const map = new Map<string | number, CalendarEvent<T>>()
160
+ for (const e of events) map.set(e.id, e)
161
+ return map
162
+ }, [events])
163
+
164
+ const handleSelectEvent = React.useCallback(
165
+ (rbcEvent: object) => {
166
+ const id = (rbcEvent as InternalRBCEvent).id
167
+ const original = eventByInternalId.get(id)
168
+ if (original && onEventClick) onEventClick(original)
169
+ },
170
+ [eventByInternalId, onEventClick],
171
+ )
172
+
173
+ const handleSelectSlot = React.useCallback(
174
+ (slot: SlotInfo) => {
175
+ if (!onSlotClick) return
176
+ onSlotClick({
177
+ start: slot.start as Date,
178
+ end: slot.end as Date,
179
+ action: slot.action === "select" ? "select" : "click",
180
+ })
181
+ },
182
+ [onSlotClick],
183
+ )
184
+
185
+ const handleViewChange = React.useCallback(
186
+ (v: View) => {
187
+ if (!onViewChange) return
188
+ const mode = (Object.entries(VIEW_BY_MODE).find(([, val]) => val === v)?.[0] ?? "month") as CalendarViewMode
189
+ onViewChange(mode)
190
+ },
191
+ [onViewChange],
192
+ )
193
+
194
+ const allowedViews = React.useMemo<View[]>(
195
+ () => (views ?? ["month", "week", "day", "agenda"]).map(v => VIEW_BY_MODE[v]),
196
+ [views],
197
+ )
198
+
199
+ return (
200
+ <div className={className} style={{ minHeight }}>
201
+ <Calendar
202
+ localizer={localizer}
203
+ events={normalized}
204
+ startAccessor="start"
205
+ endAccessor="end"
206
+ titleAccessor="title"
207
+ allDayAccessor="allDay"
208
+ date={date}
209
+ defaultDate={defaultDate}
210
+ view={view ? VIEW_BY_MODE[view] : undefined}
211
+ defaultView={defaultView ? VIEW_BY_MODE[defaultView] : undefined}
212
+ views={allowedViews}
213
+ onSelectEvent={handleSelectEvent}
214
+ onSelectSlot={handleSelectSlot}
215
+ onNavigate={onNavigate}
216
+ onView={handleViewChange}
217
+ selectable={selectable}
218
+ style={{ height: "100%", minHeight }}
219
+ />
220
+ </div>
221
+ )
222
+ }
@@ -0,0 +1,13 @@
1
+ export { CalendarView } from "./calendar-view"
2
+ export type {
3
+ CalendarEvent,
4
+ CalendarViewMode,
5
+ CalendarViewProps,
6
+ } from "./calendar-view"
7
+
8
+ export { UpcomingMeetings } from "./upcoming-meetings"
9
+ export type {
10
+ UpcomingMeeting,
11
+ UpcomingMeetingStatus,
12
+ UpcomingMeetingsProps,
13
+ } from "./upcoming-meetings"
@@ -0,0 +1,211 @@
1
+ "use client"
2
+
3
+ /**
4
+ * UpcomingMeetings — sidebar/widget listing the next N meetings with status,
5
+ * date, time, and an optional join-link.
6
+ *
7
+ * Pure presentational. The consumer owns the data fetch + filtering — pass
8
+ * the already-filtered, already-sorted meetings via the `meetings` prop. This
9
+ * keeps the component decoupled from any specific API client or data shape
10
+ * beyond the small interface declared below.
11
+ *
12
+ * lifecycle:calendar-ui (raise-simpli-azc)
13
+ */
14
+
15
+ import * as React from "react"
16
+ import { CalendarDays, Clock, ExternalLink, Plus } from "lucide-react"
17
+ import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
18
+ import { Button } from "../ui/button"
19
+ import { cn } from "../../utils"
20
+
21
+ export type UpcomingMeetingStatus = "pending" | "confirmed" | "cancelled" | "completed"
22
+
23
+ export interface UpcomingMeeting {
24
+ id: string | number
25
+ title: string
26
+ /** ISO 8601 timestamp string */
27
+ scheduled_at: string
28
+ status: UpcomingMeetingStatus
29
+ investor_name?: string
30
+ investor_email?: string
31
+ meeting_link?: string
32
+ }
33
+
34
+ export interface UpcomingMeetingsProps {
35
+ meetings: UpcomingMeeting[]
36
+ /** Whether the list is still loading. Renders skeletons. */
37
+ loading?: boolean
38
+ /** Optional click handler when a meeting row is clicked (whole row). */
39
+ onMeetingClick?: (meeting: UpcomingMeeting) => void
40
+ /** Optional URL for the "View all" header link. Hidden if absent. */
41
+ viewAllHref?: string
42
+ /** Optional click handler for the "View all" header link. Used instead of href if provided. */
43
+ onViewAll?: () => void
44
+ /** Optional click handler for the empty-state "Schedule one" CTA. Hidden if absent. */
45
+ onSchedule?: () => void
46
+ /** Custom class name for the outer Card. */
47
+ className?: string
48
+ /** Title shown in the card header. Defaults to "Upcoming Meetings". */
49
+ title?: string
50
+ /** Number of skeleton rows to show while loading. Defaults to 3. */
51
+ loadingRowCount?: number
52
+ }
53
+
54
+ const STATUS_BADGE: Record<UpcomingMeetingStatus, { label: string; className: string }> = {
55
+ pending: { label: "Pending", className: "bg-yellow-100 text-yellow-800" },
56
+ confirmed: { label: "Confirmed", className: "bg-green-100 text-green-800" },
57
+ cancelled: { label: "Cancelled", className: "bg-red-100 text-red-800" },
58
+ completed: { label: "Completed", className: "bg-gray-100 text-gray-800" },
59
+ }
60
+
61
+ function formatDate(isoStr: string): string {
62
+ return new Date(isoStr).toLocaleDateString("en-US", {
63
+ weekday: "short",
64
+ month: "short",
65
+ day: "numeric",
66
+ })
67
+ }
68
+
69
+ function formatTime(isoStr: string): string {
70
+ return new Date(isoStr).toLocaleTimeString("en-US", {
71
+ hour: "2-digit",
72
+ minute: "2-digit",
73
+ })
74
+ }
75
+
76
+ /**
77
+ * @example
78
+ * <UpcomingMeetings
79
+ * meetings={upcoming}
80
+ * loading={isLoading}
81
+ * viewAllHref="/calendar"
82
+ * onSchedule={() => setShowSchedule(true)}
83
+ * />
84
+ */
85
+ export function UpcomingMeetings({
86
+ meetings,
87
+ loading = false,
88
+ onMeetingClick,
89
+ viewAllHref,
90
+ onViewAll,
91
+ onSchedule,
92
+ className,
93
+ title = "Upcoming Meetings",
94
+ loadingRowCount = 3,
95
+ }: UpcomingMeetingsProps) {
96
+ return (
97
+ <Card className={className}>
98
+ <CardHeader className="pb-3">
99
+ <div className="flex items-center justify-between">
100
+ <CardTitle className="text-sm font-semibold flex items-center gap-2">
101
+ <CalendarDays className="h-4 w-4" />
102
+ {title}
103
+ </CardTitle>
104
+ {(viewAllHref || onViewAll) && (
105
+ <Button
106
+ variant="ghost"
107
+ size="sm"
108
+ className="h-7 text-xs"
109
+ asChild={!!viewAllHref && !onViewAll}
110
+ onClick={onViewAll}
111
+ >
112
+ {viewAllHref && !onViewAll ? <a href={viewAllHref}>View all</a> : <span>View all</span>}
113
+ </Button>
114
+ )}
115
+ </div>
116
+ </CardHeader>
117
+
118
+ <CardContent className="pt-0">
119
+ {loading ? (
120
+ <div className="space-y-2">
121
+ {Array.from({ length: loadingRowCount }).map((_, i) => (
122
+ <div key={i} className="h-14 bg-muted animate-pulse rounded-md" />
123
+ ))}
124
+ </div>
125
+ ) : meetings.length === 0 ? (
126
+ <div className="text-center py-6 text-muted-foreground">
127
+ <CalendarDays className="h-8 w-8 mx-auto mb-2 opacity-40" />
128
+ <p className="text-xs">No upcoming meetings</p>
129
+ {onSchedule && (
130
+ <Button variant="outline" size="sm" className="mt-3 h-7 text-xs" onClick={onSchedule}>
131
+ <Plus className="mr-1 h-3 w-3" />
132
+ Schedule one
133
+ </Button>
134
+ )}
135
+ </div>
136
+ ) : (
137
+ <div className="space-y-2">
138
+ {meetings.map(m => {
139
+ const badge = STATUS_BADGE[m.status]
140
+ const interactive = !!onMeetingClick
141
+ return (
142
+ <div
143
+ key={m.id}
144
+ role={interactive ? "button" : undefined}
145
+ tabIndex={interactive ? 0 : undefined}
146
+ onClick={interactive ? () => onMeetingClick(m) : undefined}
147
+ onKeyDown={
148
+ interactive
149
+ ? e => {
150
+ if (e.key === "Enter" || e.key === " ") {
151
+ e.preventDefault()
152
+ onMeetingClick(m)
153
+ }
154
+ }
155
+ : undefined
156
+ }
157
+ className={cn(
158
+ "border rounded-lg p-2.5 space-y-1 transition-colors",
159
+ interactive && "hover:bg-muted/40 cursor-pointer",
160
+ )}
161
+ >
162
+ <div className="flex items-start justify-between gap-2">
163
+ <p className="font-medium text-xs leading-tight line-clamp-1">{m.title}</p>
164
+ <span
165
+ className={cn(
166
+ "text-[10px] px-1.5 py-0.5 rounded font-medium whitespace-nowrap shrink-0",
167
+ badge.className,
168
+ )}
169
+ >
170
+ {badge.label}
171
+ </span>
172
+ </div>
173
+
174
+ {(m.investor_name || m.investor_email) && (
175
+ <p className="text-[11px] text-muted-foreground truncate">
176
+ {m.investor_name || m.investor_email}
177
+ </p>
178
+ )}
179
+
180
+ <div className="flex items-center gap-3 text-[11px] text-muted-foreground">
181
+ <span className="flex items-center gap-1">
182
+ <CalendarDays className="h-3 w-3" />
183
+ {formatDate(m.scheduled_at)}
184
+ </span>
185
+ <span className="flex items-center gap-1">
186
+ <Clock className="h-3 w-3" />
187
+ {formatTime(m.scheduled_at)}
188
+ </span>
189
+ </div>
190
+
191
+ {m.meeting_link && (
192
+ <a
193
+ href={m.meeting_link}
194
+ target="_blank"
195
+ rel="noreferrer"
196
+ onClick={e => e.stopPropagation()}
197
+ className="flex items-center gap-1 text-[11px] text-primary hover:underline"
198
+ >
199
+ <ExternalLink className="h-3 w-3" />
200
+ Join
201
+ </a>
202
+ )}
203
+ </div>
204
+ )
205
+ })}
206
+ </div>
207
+ )}
208
+ </CardContent>
209
+ </Card>
210
+ )
211
+ }
@@ -110,6 +110,9 @@ export * from './lists'
110
110
  // Pipeline components (StageTransitionModal)
111
111
  export * from './pipeline'
112
112
 
113
+ // Calendar (month/week/day event grid wrapping react-big-calendar)
114
+ export * from './calendar'
115
+
113
116
  // Activity components (timeline, quick log, log dialog)
114
117
  export { ActivityTimeline } from './ActivityTimeline'
115
118
  export type { ActivityTimelineProps, ActivityTimelineItem } from './ActivityTimeline'