@gobrand/react-calendar 0.0.19 → 0.0.21
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/README.md +25 -840
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -3,866 +3,51 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@gobrand/react-calendar)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
React
|
|
6
|
+
**React hooks for building calendars with the Temporal API.** Type-safe views, timezone-aware, optimized state management.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
👉 **[Documentation](https://eng.gobrand.app/calendar)**
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
**Peer dependencies:**
|
|
17
|
-
- `react ^18.0.0 || ^19.0.0`
|
|
18
|
-
|
|
19
|
-
## Why @gobrand/react-calendar?
|
|
20
|
-
|
|
21
|
-
Building React calendar UIs is complex: state management, timezone handling, DST transitions, multi-view navigation, and data mapping. **@gobrand/react-calendar** provides a complete React solution:
|
|
22
|
-
|
|
23
|
-
- **🪝 Simple React Hook** - Single `useCalendar()` hook with reactive state management
|
|
24
|
-
- **📅 Multi-view support** - Month, week, and day views with type-safe view switching
|
|
25
|
-
- **🌍 Timezone-aware** - Native timezone support with Temporal API primitives
|
|
26
|
-
- **🎯 Data-agnostic** - Works with any data type through accessor pattern
|
|
27
|
-
- **⚡️ Type-safe** - Full TypeScript support with conditional types based on configured views
|
|
28
|
-
- **⚙️ TanStack Store** - Optimized state management with TanStack Store
|
|
29
|
-
- **🔧 Zero config** - Sensible defaults, customize only what you need
|
|
30
|
-
- **📦 Minimal** - Built on [@gobrand/calendar-core](https://www.npmjs.com/package/@gobrand/calendar-core) for framework-agnostic logic
|
|
31
|
-
|
|
32
|
-
**Key features:**
|
|
33
|
-
- ✅ Built exclusively on Temporal API (no Date objects, no moment.js, no date-fns)
|
|
34
|
-
- ✅ Automatic DST handling and timezone conversions
|
|
35
|
-
- ✅ Type-safe view methods (only available methods for configured views)
|
|
36
|
-
- ✅ Calendar-aware arithmetic (leap years, month-end dates)
|
|
37
|
-
- ✅ Flexible accessor pattern for any data structure
|
|
38
|
-
- ✅ Polyfill included for browser compatibility
|
|
39
|
-
- ✅ All core utilities re-exported for convenience
|
|
40
|
-
|
|
41
|
-
**Perfect for:**
|
|
42
|
-
- React event calendars and schedulers
|
|
43
|
-
- Booking systems and appointment managers
|
|
44
|
-
- Task management with due dates
|
|
45
|
-
- Analytics dashboards with date ranges
|
|
46
|
-
- Any React app that needs calendar navigation
|
|
47
|
-
|
|
48
|
-
## Quick Start
|
|
49
|
-
|
|
50
|
-
```tsx
|
|
51
|
-
import { useCalendar, createCalendarViews, createCalendarAccessor, getWeekdays } from '@gobrand/react-calendar';
|
|
52
|
-
import { Temporal } from '@js-temporal/polyfill';
|
|
53
|
-
|
|
54
|
-
type Event = {
|
|
55
|
-
id: string;
|
|
56
|
-
title: string;
|
|
57
|
-
start: Temporal.ZonedDateTime;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const events: Event[] = [
|
|
61
|
-
{
|
|
62
|
-
id: '1',
|
|
63
|
-
title: 'Team Meeting',
|
|
64
|
-
start: Temporal.ZonedDateTime.from('2025-01-20T14:00:00-05:00[America/New_York]')
|
|
65
|
-
}
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
const accessor = createCalendarAccessor<Event>({
|
|
69
|
-
getDate: (event) => event.start.toPlainDate(),
|
|
70
|
-
getStart: (event) => event.start,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
function MyCalendar() {
|
|
74
|
-
const calendar = useCalendar({
|
|
75
|
-
data: events,
|
|
76
|
-
views: createCalendarViews<Event>()({
|
|
77
|
-
month: { accessor },
|
|
78
|
-
}),
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
const month = calendar.getMonth();
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
<div>
|
|
85
|
-
<header>
|
|
86
|
-
<button onClick={calendar.previousMonth}>←</button>
|
|
87
|
-
<h2>{calendar.getTitle('month')}</h2>
|
|
88
|
-
<button onClick={calendar.nextMonth}>→</button>
|
|
89
|
-
<button onClick={calendar.goToToday}>Today</button>
|
|
90
|
-
</header>
|
|
91
|
-
|
|
92
|
-
<div className="grid grid-cols-7 gap-2">
|
|
93
|
-
{getWeekdays(1).map(day => (
|
|
94
|
-
<div key={day}>{day}</div>
|
|
95
|
-
))}
|
|
96
|
-
|
|
97
|
-
{month.weeks.flat().map(day => (
|
|
98
|
-
<div
|
|
99
|
-
key={day.date.toString()}
|
|
100
|
-
className={`
|
|
101
|
-
${!day.isCurrentMonth && 'opacity-40'}
|
|
102
|
-
${day.isToday && 'bg-blue-100'}
|
|
103
|
-
`}
|
|
104
|
-
>
|
|
105
|
-
<div>{day.date.day}</div>
|
|
106
|
-
{day.items.map(event => (
|
|
107
|
-
<div key={event.id}>{event.title}</div>
|
|
108
|
-
))}
|
|
109
|
-
</div>
|
|
110
|
-
))}
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
> **Note:** For vanilla JavaScript/TypeScript usage, check out [@gobrand/calendar-core](https://www.npmjs.com/package/@gobrand/calendar-core) which provides the underlying framework-agnostic functions.
|
|
118
|
-
|
|
119
|
-
## API
|
|
120
|
-
|
|
121
|
-
### React Hooks
|
|
122
|
-
|
|
123
|
-
#### `useCalendar(options)`
|
|
124
|
-
|
|
125
|
-
Create a calendar instance with reactive state management. Returns a Calendar object with type-safe methods based on configured views.
|
|
126
|
-
|
|
127
|
-
**Parameters:**
|
|
128
|
-
- `options` (CalendarOptions): Configuration object
|
|
129
|
-
- `data` (TItem[]): Array of items to display in the calendar
|
|
130
|
-
- `views` (CalendarViewOptions): View configurations (month, week, day)
|
|
131
|
-
- `timeZone` (string, optional): IANA timezone identifier (defaults to system timezone)
|
|
132
|
-
- `state` (Partial<CalendarState>, optional): Initial state override
|
|
133
|
-
- `onStateChange` (function, optional): Callback when state changes
|
|
134
|
-
|
|
135
|
-
**Returns:** `Calendar<TItem, TOptions>` - Calendar instance with conditional methods
|
|
136
|
-
|
|
137
|
-
**Example:**
|
|
138
|
-
```tsx
|
|
139
|
-
import { useCalendar, createCalendarViews, createCalendarAccessor } from '@gobrand/react-calendar';
|
|
140
|
-
|
|
141
|
-
type Event = {
|
|
142
|
-
id: string;
|
|
143
|
-
title: string;
|
|
144
|
-
start: Temporal.ZonedDateTime;
|
|
145
|
-
end?: Temporal.ZonedDateTime;
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
const accessor = createCalendarAccessor<Event>({
|
|
149
|
-
getDate: (event) => event.start.toPlainDate(),
|
|
150
|
-
getStart: (event) => event.start,
|
|
151
|
-
getEnd: (event) => event.end,
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
const calendar = useCalendar({
|
|
155
|
-
data: events,
|
|
156
|
-
timeZone: 'America/New_York',
|
|
157
|
-
views: createCalendarViews<Event>()({
|
|
158
|
-
month: { accessor },
|
|
159
|
-
week: { startHour: 8, endHour: 18, accessor },
|
|
160
|
-
day: { startHour: 8, endHour: 18, slotDuration: 30, accessor },
|
|
161
|
-
}),
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// Type-safe methods - only available if view is configured
|
|
165
|
-
calendar.getMonth(); // ✓ Available (month view configured)
|
|
166
|
-
calendar.getWeek(); // ✓ Available (week view configured)
|
|
167
|
-
calendar.getDay(); // ✓ Available (day view configured)
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
### Core Calendar Methods
|
|
171
|
-
|
|
172
|
-
#### View Methods
|
|
173
|
-
|
|
174
|
-
##### `getMonth()`
|
|
175
|
-
|
|
176
|
-
Get the current month view. Only available if month view is configured.
|
|
177
|
-
|
|
178
|
-
**Returns:** `CalendarMonth<TItem>` - Month grid with weeks and days
|
|
179
|
-
- `weeks` - Array of weeks, each containing 7 days
|
|
180
|
-
- `month` - Temporal.PlainYearMonth for the current month
|
|
181
|
-
|
|
182
|
-
**Example:**
|
|
183
|
-
```tsx
|
|
184
|
-
const month = calendar.getMonth();
|
|
185
|
-
|
|
186
|
-
month.weeks.forEach(week => {
|
|
187
|
-
week.forEach(day => {
|
|
188
|
-
console.log(day.date, day.items, day.isToday, day.isCurrentMonth);
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
##### `getWeek()`
|
|
194
|
-
|
|
195
|
-
Get the current week view. Only available if week view is configured.
|
|
196
|
-
|
|
197
|
-
**Returns:** `CalendarWeekView<TItem>` - Week with days and optional time slots
|
|
198
|
-
- `days` - Array of 7 days in the week
|
|
199
|
-
- `weekStart` - Temporal.PlainDate for the first day
|
|
200
|
-
- `weekEnd` - Temporal.PlainDate for the last day
|
|
201
|
-
|
|
202
|
-
**Example:**
|
|
203
|
-
```tsx
|
|
204
|
-
const week = calendar.getWeek();
|
|
205
|
-
|
|
206
|
-
week.days.forEach(day => {
|
|
207
|
-
console.log(day.date, day.items);
|
|
208
|
-
day.timeSlots?.forEach(slot => {
|
|
209
|
-
console.log(slot.hour, slot.minute, slot.items);
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
##### `getDay()`
|
|
215
|
-
|
|
216
|
-
Get the current day view. Only available if day view is configured.
|
|
217
|
-
|
|
218
|
-
**Returns:** `CalendarDayView<TItem>` - Day with time slots
|
|
219
|
-
- `date` - Temporal.PlainDate for the day
|
|
220
|
-
- `isToday` - Boolean indicating if this is today
|
|
221
|
-
- `timeSlots` - Array of time slots with items
|
|
222
|
-
- `items` - All items for this day
|
|
223
|
-
|
|
224
|
-
**Example:**
|
|
225
|
-
```tsx
|
|
226
|
-
const day = calendar.getDay();
|
|
227
|
-
|
|
228
|
-
console.log(day.date, day.isToday);
|
|
229
|
-
day.timeSlots.forEach(slot => {
|
|
230
|
-
console.log(`${slot.hour}:${slot.minute}`, slot.items);
|
|
231
|
-
});
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
#### Navigation Methods
|
|
235
|
-
|
|
236
|
-
##### Month Navigation
|
|
237
|
-
|
|
238
|
-
```tsx
|
|
239
|
-
calendar.nextMonth(); // Go to next month
|
|
240
|
-
calendar.previousMonth(); // Go to previous month
|
|
241
|
-
calendar.goToMonth(2025, 6); // Go to specific month (year, month)
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
##### Week Navigation
|
|
245
|
-
|
|
246
|
-
```tsx
|
|
247
|
-
calendar.nextWeek(); // Go to next week
|
|
248
|
-
calendar.previousWeek(); // Go to previous week
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
##### Day Navigation
|
|
252
|
-
|
|
253
|
-
```tsx
|
|
254
|
-
calendar.nextDay(); // Go to next day
|
|
255
|
-
calendar.previousDay(); // Go to previous day
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
##### Universal Navigation
|
|
259
|
-
|
|
260
|
-
```tsx
|
|
261
|
-
calendar.goToToday(); // Go to today (works for all views)
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
#### View Management
|
|
265
|
-
|
|
266
|
-
##### `setCurrentView(view)`
|
|
267
|
-
|
|
268
|
-
Switch between configured views.
|
|
269
|
-
|
|
270
|
-
**Parameters:**
|
|
271
|
-
- `view` (string): One of the configured view names ('month' | 'week' | 'day')
|
|
272
|
-
|
|
273
|
-
**Example:**
|
|
274
|
-
```tsx
|
|
275
|
-
calendar.setCurrentView('week'); // Switch to week view
|
|
276
|
-
calendar.setCurrentView('month'); // Switch to month view
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
##### `getTitle(view?, locales?, options?)`
|
|
280
|
-
|
|
281
|
-
Get a formatted title for the current view or a specific view.
|
|
282
|
-
|
|
283
|
-
**Parameters:**
|
|
284
|
-
- `view` (string, optional): View name (defaults to current view)
|
|
285
|
-
- `locales` (string | string[], optional): Locale(s) for formatting
|
|
286
|
-
- `options` (Intl.DateTimeFormatOptions, optional): Formatting options
|
|
287
|
-
|
|
288
|
-
**Returns:** `string` - Formatted title
|
|
289
|
-
|
|
290
|
-
**Example:**
|
|
291
|
-
```tsx
|
|
292
|
-
calendar.getTitle('month'); // "January 2025"
|
|
293
|
-
calendar.getTitle('month', 'es-ES'); // "enero de 2025"
|
|
294
|
-
calendar.getTitle('week'); // "Jan 20 – 26, 2025"
|
|
295
|
-
calendar.getTitle('day', 'en-US', {
|
|
296
|
-
weekday: 'long',
|
|
297
|
-
year: 'numeric',
|
|
298
|
-
month: 'long',
|
|
299
|
-
day: 'numeric'
|
|
300
|
-
}); // "Monday, January 20, 2025"
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
#### State Management
|
|
304
|
-
|
|
305
|
-
##### `getState()`
|
|
306
|
-
|
|
307
|
-
Get the current calendar state.
|
|
308
|
-
|
|
309
|
-
**Returns:** `CalendarState` - Current state object
|
|
310
|
-
- `currentView` - Active view name
|
|
311
|
-
- `referenceDate` - Current reference date (PlainDate)
|
|
312
|
-
|
|
313
|
-
##### `setState(updater)`
|
|
314
|
-
|
|
315
|
-
Update the calendar state.
|
|
316
|
-
|
|
317
|
-
**Parameters:**
|
|
318
|
-
- `updater` (function | object): State update function or partial state object
|
|
10
|
+
- **Built on Temporal API** - No Date objects, no moment.js, no date-fns
|
|
11
|
+
- **Timezone-aware** - Native DST handling with IANA timezones
|
|
12
|
+
- **Type-safe** - Conditional methods based on configured views
|
|
13
|
+
- **Multi-view** - Month, week, and day views with time slots
|
|
14
|
+
- **TanStack Store** - Optimized reactive state management
|
|
319
15
|
|
|
320
|
-
**Example:**
|
|
321
16
|
```tsx
|
|
322
|
-
|
|
323
|
-
calendar.setState(state => ({
|
|
324
|
-
...state,
|
|
325
|
-
referenceDate: Temporal.PlainDate.from('2025-12-25')
|
|
326
|
-
}));
|
|
17
|
+
import { useCreateCalendar, useView, CalendarProvider } from '@gobrand/react-calendar';
|
|
327
18
|
|
|
328
|
-
|
|
329
|
-
calendar.setState({ currentView: 'week' });
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
### Accessor Pattern
|
|
333
|
-
|
|
334
|
-
#### `createCalendarAccessor(accessor)`
|
|
335
|
-
|
|
336
|
-
Create a type-safe accessor for mapping your data to calendar dates. This is a type-identity function for TypeScript inference.
|
|
337
|
-
|
|
338
|
-
**Parameters:**
|
|
339
|
-
- `accessor` (CalendarAccessor<TItem>): Accessor configuration
|
|
340
|
-
- `getDate` (required): Extract PlainDate from item
|
|
341
|
-
- `getStart` (optional): Extract ZonedDateTime start time
|
|
342
|
-
- `getEnd` (optional): Extract ZonedDateTime end time
|
|
343
|
-
|
|
344
|
-
**Returns:** Same accessor object with proper types
|
|
345
|
-
|
|
346
|
-
**Example:**
|
|
347
|
-
```tsx
|
|
348
|
-
// Simple date-based items (tasks, posts)
|
|
349
|
-
type Task = {
|
|
350
|
-
id: string;
|
|
351
|
-
name: string;
|
|
352
|
-
dueDate: Temporal.PlainDate;
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
const taskAccessor = createCalendarAccessor<Task>({
|
|
356
|
-
getDate: (task) => task.dueDate,
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
// Time-based items with start time (events, appointments)
|
|
360
|
-
type Event = {
|
|
361
|
-
id: string;
|
|
362
|
-
title: string;
|
|
363
|
-
start: Temporal.ZonedDateTime;
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
const eventAccessor = createCalendarAccessor<Event>({
|
|
367
|
-
getDate: (event) => event.start.toPlainDate(),
|
|
368
|
-
getStart: (event) => event.start,
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
// Items with start and end times (meetings, bookings)
|
|
372
|
-
type Meeting = {
|
|
373
|
-
id: string;
|
|
374
|
-
title: string;
|
|
375
|
-
start: Temporal.ZonedDateTime;
|
|
376
|
-
end: Temporal.ZonedDateTime;
|
|
377
|
-
};
|
|
378
|
-
|
|
379
|
-
const meetingAccessor = createCalendarAccessor<Meeting>({
|
|
380
|
-
getDate: (meeting) => meeting.start.toPlainDate(),
|
|
381
|
-
getStart: (meeting) => meeting.start,
|
|
382
|
-
getEnd: (meeting) => meeting.end,
|
|
383
|
-
});
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
### View Configuration
|
|
387
|
-
|
|
388
|
-
#### `createCalendarViews()`
|
|
389
|
-
|
|
390
|
-
Create type-safe view configurations. This is a curried function that requires a type parameter.
|
|
391
|
-
|
|
392
|
-
**Usage:**
|
|
393
|
-
```tsx
|
|
394
|
-
const views = createCalendarViews<TItem>()({
|
|
395
|
-
month?: { ... },
|
|
396
|
-
week?: { ... },
|
|
397
|
-
day?: { ... }
|
|
398
|
-
});
|
|
399
|
-
```
|
|
400
|
-
|
|
401
|
-
#### Month View Options
|
|
402
|
-
|
|
403
|
-
```tsx
|
|
404
|
-
type MonthViewOptions<TItem> = {
|
|
405
|
-
accessor: CalendarAccessor<TItem>;
|
|
406
|
-
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday, 1 = Monday, etc.
|
|
407
|
-
};
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
#### Week View Options
|
|
411
|
-
|
|
412
|
-
```tsx
|
|
413
|
-
type WeekViewOptions<TItem> = {
|
|
414
|
-
accessor: CalendarAccessor<TItem>;
|
|
415
|
-
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
416
|
-
startHour?: number; // Default: 0
|
|
417
|
-
endHour?: number; // Default: 24
|
|
418
|
-
slotDuration?: number; // Minutes per slot, default: 60
|
|
419
|
-
};
|
|
420
|
-
```
|
|
421
|
-
|
|
422
|
-
#### Day View Options
|
|
423
|
-
|
|
424
|
-
```tsx
|
|
425
|
-
type DayViewOptions<TItem> = {
|
|
426
|
-
accessor: CalendarAccessor<TItem>;
|
|
427
|
-
startHour?: number; // Default: 0
|
|
428
|
-
endHour?: number; // Default: 24
|
|
429
|
-
slotDuration?: number; // Minutes per slot, default: 60
|
|
430
|
-
};
|
|
431
|
-
```
|
|
432
|
-
|
|
433
|
-
### Core Utilities (Re-exported)
|
|
434
|
-
|
|
435
|
-
All utilities from [@gobrand/calendar-core](https://www.npmjs.com/package/@gobrand/calendar-core) are re-exported for convenience. For detailed documentation on these functions, see the [core package documentation](https://www.npmjs.com/package/@gobrand/calendar-core).
|
|
436
|
-
|
|
437
|
-
**Building Functions:**
|
|
438
|
-
- `buildMonth(year, month, options?)` - Build month grid
|
|
439
|
-
- `buildWeek(date, options?)` - Build week view
|
|
440
|
-
- `buildDay(date, options?)` - Build day view with time slots
|
|
441
|
-
|
|
442
|
-
**Formatting:**
|
|
443
|
-
- `getWeekdays(weekStartsOn?, locale?, format?)` - Localized weekday names
|
|
444
|
-
- `getMonthName(month, locale?)` - Localized month name
|
|
445
|
-
- `formatTime(time, locale?)` - Format PlainTime
|
|
446
|
-
|
|
447
|
-
**Timezone:**
|
|
448
|
-
- `getMonthRange(timeZone?, weekStartsOn?)` - Week-aligned month range
|
|
449
|
-
- `getWeekRange(timeZone?, weekStartsOn?)` - Current week range
|
|
450
|
-
- `getDayRange(timeZone?)` - Today in timezone
|
|
451
|
-
- `getCurrentTimeZone()` - Get system timezone
|
|
452
|
-
- `convertToTimezone(zdt, timeZone)` - Convert between timezones
|
|
453
|
-
- `createZonedDateTime(date, time, timeZone)` - Create ZonedDateTime
|
|
454
|
-
|
|
455
|
-
For detailed documentation and examples, see [@gobrand/calendar-core](https://www.npmjs.com/package/@gobrand/calendar-core).
|
|
456
|
-
|
|
457
|
-
## Real World Examples
|
|
458
|
-
|
|
459
|
-
### Example 1: Fetching Calendar Data with useQuery and Date-Range Pagination
|
|
460
|
-
|
|
461
|
-
This is the most common real-world pattern: fetching data from an API based on the visible calendar date range. As users navigate the calendar (month/week/day), new data is automatically fetched for that time period.
|
|
462
|
-
|
|
463
|
-
```tsx
|
|
464
|
-
import { useQuery } from '@tanstack/react-query';
|
|
465
|
-
import { toZonedTime, now } from '@gobrand/tiempo';
|
|
466
|
-
import {
|
|
467
|
-
useCalendar,
|
|
468
|
-
createCalendarViews,
|
|
469
|
-
createCalendarAccessor,
|
|
470
|
-
type DateRange,
|
|
471
|
-
getMonthDateRange,
|
|
472
|
-
} from '@gobrand/react-calendar';
|
|
473
|
-
import { useState } from 'react';
|
|
474
|
-
|
|
475
|
-
type Post = {
|
|
476
|
-
id: string;
|
|
477
|
-
title: string;
|
|
478
|
-
publishedAt: string; // UTC ISO 8601 string from API
|
|
479
|
-
status: 'draft' | 'scheduled' | 'published';
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
type PostWithDateTime = Post & {
|
|
483
|
-
zonedDateTime: Temporal.ZonedDateTime;
|
|
484
|
-
};
|
|
485
|
-
|
|
486
|
-
const TIME_ZONE = 'America/New_York';
|
|
487
|
-
|
|
488
|
-
function PostCalendar() {
|
|
489
|
-
// Track the current visible date range
|
|
490
|
-
const [dateRange, setDateRange] = useState<DateRange>(() =>
|
|
491
|
-
getMonthDateRange(now(TIME_ZONE).toPlainDate(), TIME_ZONE)
|
|
492
|
-
);
|
|
493
|
-
|
|
494
|
-
// Fetch posts for the visible date range
|
|
495
|
-
const { data: posts = [], isLoading } = useQuery({
|
|
496
|
-
queryKey: ['posts', dateRange?.start.toString(), dateRange?.end.toString()],
|
|
497
|
-
queryFn: async () => {
|
|
498
|
-
if (!dateRange) return [];
|
|
499
|
-
|
|
500
|
-
// Convert date range to UTC ISO strings for API
|
|
501
|
-
const filters = {
|
|
502
|
-
dateRange: {
|
|
503
|
-
start: dateRange.start.toInstant().toString(),
|
|
504
|
-
end: dateRange.end.toInstant().toString(),
|
|
505
|
-
},
|
|
506
|
-
};
|
|
507
|
-
|
|
508
|
-
return getPosts(filters);
|
|
509
|
-
},
|
|
510
|
-
// Convert UTC strings to ZonedDateTime for calendar
|
|
511
|
-
select: (posts) =>
|
|
512
|
-
posts.map((post) => ({
|
|
513
|
-
...post,
|
|
514
|
-
zonedDateTime: toZonedTime(post.publishedAt, TIME_ZONE),
|
|
515
|
-
})),
|
|
516
|
-
enabled: !!dateRange,
|
|
517
|
-
});
|
|
19
|
+
const accessor = { getDate: (e: Event) => e.date };
|
|
518
20
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
views: createCalendarViews<PostWithDateTime>()({
|
|
523
|
-
month: {
|
|
524
|
-
accessor: createCalendarAccessor({
|
|
525
|
-
getDate: (post) => post.zonedDateTime.toPlainDate(),
|
|
526
|
-
getStart: (post) => post.zonedDateTime,
|
|
527
|
-
}),
|
|
528
|
-
},
|
|
529
|
-
}),
|
|
530
|
-
// Sync date range when calendar navigation changes
|
|
531
|
-
onStateChange: (updater) => {
|
|
532
|
-
const newState =
|
|
533
|
-
typeof updater === 'function' ? updater(calendar.getState()) : updater;
|
|
534
|
-
setDateRange(newState.dateRange);
|
|
535
|
-
},
|
|
21
|
+
function App() {
|
|
22
|
+
const calendar = useCreateCalendar<Event>({
|
|
23
|
+
views: { month: { accessor } },
|
|
536
24
|
});
|
|
537
25
|
|
|
538
|
-
const month = calendar.getMonth();
|
|
539
|
-
|
|
540
26
|
return (
|
|
541
|
-
<
|
|
542
|
-
<
|
|
543
|
-
|
|
544
|
-
<h2>{calendar.getTitle('month')}</h2>
|
|
545
|
-
<button onClick={calendar.nextMonth}>→</button>
|
|
546
|
-
{isLoading && <span>Loading...</span>}
|
|
547
|
-
</header>
|
|
548
|
-
|
|
549
|
-
<div className="calendar-grid">
|
|
550
|
-
{month.weeks.flat().map((day) => (
|
|
551
|
-
<div key={day.date.toString()}>
|
|
552
|
-
<div>{day.date.day}</div>
|
|
553
|
-
{day.items.map((post) => (
|
|
554
|
-
<div key={post.id}>{post.title}</div>
|
|
555
|
-
))}
|
|
556
|
-
</div>
|
|
557
|
-
))}
|
|
558
|
-
</div>
|
|
559
|
-
</div>
|
|
27
|
+
<CalendarProvider calendar={calendar}>
|
|
28
|
+
<Calendar />
|
|
29
|
+
</CalendarProvider>
|
|
560
30
|
);
|
|
561
31
|
}
|
|
562
|
-
```
|
|
563
|
-
|
|
564
|
-
**Key points:**
|
|
565
|
-
- `dateRange` state tracks the currently visible calendar period
|
|
566
|
-
- `useQuery` automatically refetches when `dateRange` changes (navigation)
|
|
567
|
-
- Date range is converted to UTC ISO strings for the API request
|
|
568
|
-
- `select` transforms API responses (UTC ISO strings) to `ZonedDateTime` for the calendar
|
|
569
|
-
- `onStateChange` syncs calendar navigation with the date range state
|
|
570
|
-
- This pattern works for infinite scroll, cursor-based pagination, or any date-range filtering
|
|
571
|
-
|
|
572
|
-
### Example 2: Event Calendar with Multi-View Support
|
|
573
|
-
|
|
574
|
-
A complete event calendar with month, week, and day views, timezone support, and type-safe view switching.
|
|
575
|
-
|
|
576
|
-
```tsx
|
|
577
|
-
import {
|
|
578
|
-
useCalendar,
|
|
579
|
-
createCalendarViews,
|
|
580
|
-
createCalendarAccessor,
|
|
581
|
-
getWeekdays
|
|
582
|
-
} from '@gobrand/react-calendar';
|
|
583
|
-
import { Temporal } from '@js-temporal/polyfill';
|
|
584
|
-
|
|
585
|
-
type Event = {
|
|
586
|
-
id: string;
|
|
587
|
-
title: string;
|
|
588
|
-
description?: string;
|
|
589
|
-
start: Temporal.ZonedDateTime;
|
|
590
|
-
end: Temporal.ZonedDateTime;
|
|
591
|
-
};
|
|
592
|
-
|
|
593
|
-
const events: Event[] = [
|
|
594
|
-
{
|
|
595
|
-
id: '1',
|
|
596
|
-
title: 'Team Standup',
|
|
597
|
-
start: Temporal.ZonedDateTime.from('2025-01-20T09:00:00-05:00[America/New_York]'),
|
|
598
|
-
end: Temporal.ZonedDateTime.from('2025-01-20T09:30:00-05:00[America/New_York]')
|
|
599
|
-
},
|
|
600
|
-
{
|
|
601
|
-
id: '2',
|
|
602
|
-
title: 'Client Meeting',
|
|
603
|
-
start: Temporal.ZonedDateTime.from('2025-01-20T14:00:00-05:00[America/New_York]'),
|
|
604
|
-
end: Temporal.ZonedDateTime.from('2025-01-20T15:00:00-05:00[America/New_York]')
|
|
605
|
-
}
|
|
606
|
-
];
|
|
607
|
-
|
|
608
|
-
const accessor = createCalendarAccessor<Event>({
|
|
609
|
-
getDate: (event) => event.start.toPlainDate(),
|
|
610
|
-
getStart: (event) => event.start,
|
|
611
|
-
getEnd: (event) => event.end,
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
function EventCalendar() {
|
|
615
|
-
const calendar = useCalendar({
|
|
616
|
-
data: events,
|
|
617
|
-
timeZone: 'America/New_York',
|
|
618
|
-
views: createCalendarViews<Event>()({
|
|
619
|
-
month: { weekStartsOn: 0, accessor },
|
|
620
|
-
week: { weekStartsOn: 0, startHour: 8, endHour: 18, slotDuration: 30, accessor },
|
|
621
|
-
day: { startHour: 8, endHour: 18, slotDuration: 30, accessor },
|
|
622
|
-
}),
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
const currentView = calendar.getState().currentView;
|
|
626
|
-
|
|
627
|
-
return (
|
|
628
|
-
<div className="calendar">
|
|
629
|
-
{/* Header with view switcher */}
|
|
630
|
-
<header>
|
|
631
|
-
<div className="view-buttons">
|
|
632
|
-
<button
|
|
633
|
-
onClick={() => calendar.setCurrentView('month')}
|
|
634
|
-
className={currentView === 'month' ? 'active' : ''}
|
|
635
|
-
>
|
|
636
|
-
Month
|
|
637
|
-
</button>
|
|
638
|
-
<button
|
|
639
|
-
onClick={() => calendar.setCurrentView('week')}
|
|
640
|
-
className={currentView === 'week' ? 'active' : ''}
|
|
641
|
-
>
|
|
642
|
-
Week
|
|
643
|
-
</button>
|
|
644
|
-
<button
|
|
645
|
-
onClick={() => calendar.setCurrentView('day')}
|
|
646
|
-
className={currentView === 'day' ? 'active' : ''}
|
|
647
|
-
>
|
|
648
|
-
Day
|
|
649
|
-
</button>
|
|
650
|
-
</div>
|
|
651
|
-
|
|
652
|
-
<h2>{calendar.getTitle()}</h2>
|
|
653
|
-
|
|
654
|
-
<div className="nav-buttons">
|
|
655
|
-
{currentView === 'month' && (
|
|
656
|
-
<>
|
|
657
|
-
<button onClick={calendar.previousMonth}>←</button>
|
|
658
|
-
<button onClick={calendar.goToToday}>Today</button>
|
|
659
|
-
<button onClick={calendar.nextMonth}>→</button>
|
|
660
|
-
</>
|
|
661
|
-
)}
|
|
662
|
-
{currentView === 'week' && (
|
|
663
|
-
<>
|
|
664
|
-
<button onClick={calendar.previousWeek}>←</button>
|
|
665
|
-
<button onClick={calendar.goToToday}>Today</button>
|
|
666
|
-
<button onClick={calendar.nextWeek}>→</button>
|
|
667
|
-
</>
|
|
668
|
-
)}
|
|
669
|
-
{currentView === 'day' && (
|
|
670
|
-
<>
|
|
671
|
-
<button onClick={calendar.previousDay}>←</button>
|
|
672
|
-
<button onClick={calendar.goToToday}>Today</button>
|
|
673
|
-
<button onClick={calendar.nextDay}>→</button>
|
|
674
|
-
</>
|
|
675
|
-
)}
|
|
676
|
-
</div>
|
|
677
|
-
</header>
|
|
678
|
-
|
|
679
|
-
{/* Month View */}
|
|
680
|
-
{currentView === 'month' && (
|
|
681
|
-
<div className="month-view">
|
|
682
|
-
<div className="weekday-headers">
|
|
683
|
-
{getWeekdays(0).map(day => (
|
|
684
|
-
<div key={day} className="weekday">{day}</div>
|
|
685
|
-
))}
|
|
686
|
-
</div>
|
|
687
|
-
<div className="month-grid">
|
|
688
|
-
{calendar.getMonth().weeks.flat().map(day => (
|
|
689
|
-
<div
|
|
690
|
-
key={day.date.toString()}
|
|
691
|
-
className={`day ${!day.isCurrentMonth ? 'other-month' : ''} ${day.isToday ? 'today' : ''}`}
|
|
692
|
-
>
|
|
693
|
-
<div className="day-number">{day.date.day}</div>
|
|
694
|
-
<div className="events">
|
|
695
|
-
{day.items.map(event => (
|
|
696
|
-
<div key={event.id} className="event">
|
|
697
|
-
{event.title}
|
|
698
|
-
</div>
|
|
699
|
-
))}
|
|
700
|
-
</div>
|
|
701
|
-
</div>
|
|
702
|
-
))}
|
|
703
|
-
</div>
|
|
704
|
-
</div>
|
|
705
|
-
)}
|
|
706
32
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
<div className="weekday-headers">
|
|
711
|
-
{calendar.getWeek().days.map(day => (
|
|
712
|
-
<div key={day.date.toString()} className="weekday">
|
|
713
|
-
<div>{day.date.toLocaleString('en-US', { weekday: 'short' })}</div>
|
|
714
|
-
<div className={day.isToday ? 'today' : ''}>{day.date.day}</div>
|
|
715
|
-
</div>
|
|
716
|
-
))}
|
|
717
|
-
</div>
|
|
718
|
-
<div className="week-grid">
|
|
719
|
-
{calendar.getWeek().days.map(day => (
|
|
720
|
-
<div key={day.date.toString()} className="day-column">
|
|
721
|
-
{day.timeSlots?.map((slot, i) => (
|
|
722
|
-
<div key={i} className="time-slot">
|
|
723
|
-
{slot.items.map(event => (
|
|
724
|
-
<div key={event.id} className="event">
|
|
725
|
-
{event.title}
|
|
726
|
-
</div>
|
|
727
|
-
))}
|
|
728
|
-
</div>
|
|
729
|
-
))}
|
|
730
|
-
</div>
|
|
731
|
-
))}
|
|
732
|
-
</div>
|
|
733
|
-
</div>
|
|
734
|
-
)}
|
|
735
|
-
|
|
736
|
-
{/* Day View */}
|
|
737
|
-
{currentView === 'day' && (
|
|
738
|
-
<div className="day-view">
|
|
739
|
-
{calendar.getDay().timeSlots.map((slot, i) => (
|
|
740
|
-
<div key={i} className="time-slot">
|
|
741
|
-
<div className="time">{slot.hour}:{String(slot.minute).padStart(2, '0')}</div>
|
|
742
|
-
<div className="slot-events">
|
|
743
|
-
{slot.items.map(event => (
|
|
744
|
-
<div key={event.id} className="event">
|
|
745
|
-
<strong>{event.title}</strong>
|
|
746
|
-
{event.description && <p>{event.description}</p>}
|
|
747
|
-
</div>
|
|
748
|
-
))}
|
|
749
|
-
</div>
|
|
750
|
-
</div>
|
|
751
|
-
))}
|
|
752
|
-
</div>
|
|
753
|
-
)}
|
|
754
|
-
</div>
|
|
755
|
-
);
|
|
33
|
+
function Calendar() {
|
|
34
|
+
const view = useView({ data: events });
|
|
35
|
+
// view.data.weeks.flat().map(day => ...)
|
|
756
36
|
}
|
|
757
37
|
```
|
|
758
38
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
Simple calendar showing task due dates without time information.
|
|
762
|
-
|
|
763
|
-
```tsx
|
|
764
|
-
import { useCalendar, createCalendarViews, createCalendarAccessor } from '@gobrand/react-calendar';
|
|
765
|
-
import { Temporal } from '@js-temporal/polyfill';
|
|
766
|
-
|
|
767
|
-
type Task = {
|
|
768
|
-
id: string;
|
|
769
|
-
title: string;
|
|
770
|
-
dueDate: Temporal.PlainDate;
|
|
771
|
-
completed: boolean;
|
|
772
|
-
};
|
|
773
|
-
|
|
774
|
-
const tasks: Task[] = [
|
|
775
|
-
{ id: '1', title: 'Review PR #42', dueDate: Temporal.PlainDate.from('2025-01-20'), completed: false },
|
|
776
|
-
{ id: '2', title: 'Write documentation', dueDate: Temporal.PlainDate.from('2025-01-22'), completed: false },
|
|
777
|
-
{ id: '3', title: 'Deploy to production', dueDate: Temporal.PlainDate.from('2025-01-25'), completed: true },
|
|
778
|
-
];
|
|
779
|
-
|
|
780
|
-
function TaskCalendar() {
|
|
781
|
-
const calendar = useCalendar({
|
|
782
|
-
data: tasks,
|
|
783
|
-
views: createCalendarViews<Task>()({
|
|
784
|
-
month: {
|
|
785
|
-
accessor: createCalendarAccessor({
|
|
786
|
-
getDate: (task) => task.dueDate,
|
|
787
|
-
})
|
|
788
|
-
},
|
|
789
|
-
}),
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
const month = calendar.getMonth();
|
|
793
|
-
|
|
794
|
-
return (
|
|
795
|
-
<div>
|
|
796
|
-
<header>
|
|
797
|
-
<button onClick={calendar.previousMonth}>Previous</button>
|
|
798
|
-
<h2>{calendar.getTitle('month')}</h2>
|
|
799
|
-
<button onClick={calendar.nextMonth}>Next</button>
|
|
800
|
-
</header>
|
|
801
|
-
|
|
802
|
-
<div className="calendar-grid">
|
|
803
|
-
{month.weeks.flat().map(day => (
|
|
804
|
-
<div
|
|
805
|
-
key={day.date.toString()}
|
|
806
|
-
className={!day.isCurrentMonth ? 'dimmed' : ''}
|
|
807
|
-
>
|
|
808
|
-
<div>{day.date.day}</div>
|
|
809
|
-
{day.items.map(task => (
|
|
810
|
-
<div
|
|
811
|
-
key={task.id}
|
|
812
|
-
className={task.completed ? 'completed' : 'pending'}
|
|
813
|
-
>
|
|
814
|
-
{task.title}
|
|
815
|
-
</div>
|
|
816
|
-
))}
|
|
817
|
-
</div>
|
|
818
|
-
))}
|
|
819
|
-
</div>
|
|
820
|
-
</div>
|
|
821
|
-
);
|
|
822
|
-
}
|
|
823
|
-
```
|
|
824
|
-
|
|
825
|
-
> **For vanilla JavaScript examples:** Check out [@gobrand/calendar-core documentation](https://www.npmjs.com/package/@gobrand/calendar-core) for framework-agnostic usage examples.
|
|
826
|
-
|
|
827
|
-
## Browser Support
|
|
828
|
-
|
|
829
|
-
The Temporal API is a Stage 3 TC39 proposal. The polyfill `@js-temporal/polyfill` is included as a dependency, ensuring compatibility across all modern browsers.
|
|
830
|
-
|
|
831
|
-
```typescript
|
|
832
|
-
import { Temporal } from '@js-temporal/polyfill';
|
|
833
|
-
```
|
|
834
|
-
|
|
835
|
-
**Requirements:**
|
|
836
|
-
- React 18+ or React 19+
|
|
837
|
-
- Modern browsers with ES2015+ support
|
|
838
|
-
|
|
839
|
-
## Development
|
|
39
|
+
## Install
|
|
840
40
|
|
|
841
41
|
```bash
|
|
842
|
-
|
|
843
|
-
pnpm install
|
|
844
|
-
|
|
845
|
-
# Build all packages
|
|
846
|
-
pnpm build
|
|
847
|
-
|
|
848
|
-
# Run tests
|
|
849
|
-
pnpm test --run
|
|
850
|
-
|
|
851
|
-
# Type check
|
|
852
|
-
pnpm typecheck
|
|
853
|
-
|
|
854
|
-
# Release new version
|
|
855
|
-
pnpm release <patch|minor|major>
|
|
42
|
+
pnpm add @gobrand/react-calendar
|
|
856
43
|
```
|
|
857
44
|
|
|
858
|
-
|
|
45
|
+
**Peer dependencies:** React 18+ or 19+
|
|
859
46
|
|
|
860
|
-
|
|
47
|
+
## Docs
|
|
861
48
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
MIT
|
|
49
|
+
**[eng.gobrand.app/calendar](https://eng.gobrand.app/calendar)** — Full API reference, examples, and guides.
|
|
865
50
|
|
|
866
|
-
##
|
|
51
|
+
## License
|
|
867
52
|
|
|
868
|
-
|
|
53
|
+
MIT © [Ruben Costa](https://x.com/PonziChad) / [Go Brand](https://gobrand.app)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobrand/react-calendar",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.21",
|
|
4
4
|
"description": "React hooks and components for building calendars using the Temporal API",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"bugs": {
|
|
32
32
|
"url": "https://github.com/go-brand/calendar/issues"
|
|
33
33
|
},
|
|
34
|
-
"homepage": "https://
|
|
34
|
+
"homepage": "https://eng.gobrand.app/calendar",
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@js-temporal/polyfill": "^0.5.1",
|
|
37
|
-
"@gobrand/calendar-core": "^0.0.
|
|
37
|
+
"@gobrand/calendar-core": "^0.0.21"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"react": "^18.0.0 || ^19.0.0"
|