@intinyagroup/calendar 0.1.1-alpha.8 → 0.1.2
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/dist/Calendar.svelte +142 -30
- package/dist/DatePicker.svelte +27 -13
- package/dist/DatePicker.svelte.d.ts +1 -1
- package/dist/DateRangePicker.svelte +392 -0
- package/dist/DateRangePicker.svelte.d.ts +28 -0
- package/dist/EventCalendar.svelte +1418 -0
- package/dist/EventCalendar.svelte.d.ts +48 -0
- package/dist/helpers.test.d.ts +1 -0
- package/dist/helpers.test.js +129 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.js +5 -2
- package/dist/recurrence.d.ts +31 -0
- package/dist/recurrence.js +57 -0
- package/package.json +11 -6
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type CalendarRecurrence, type CalendarResource } from "./recurrence";
|
|
2
|
+
export type CalendarView = "month" | "week" | "day" | "resourceTimeline";
|
|
3
|
+
export type CalendarEvent = {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
start: Date;
|
|
7
|
+
end: Date;
|
|
8
|
+
color?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
allDay?: boolean;
|
|
11
|
+
/** Optional minimal recurrence. See CalendarRecurrence for supported semantics. */
|
|
12
|
+
recurrence?: CalendarRecurrence;
|
|
13
|
+
/** Resource row used by resourceTimeline view. */
|
|
14
|
+
resourceId?: string;
|
|
15
|
+
};
|
|
16
|
+
type $$ComponentProps = {
|
|
17
|
+
events?: CalendarEvent[];
|
|
18
|
+
view?: CalendarView;
|
|
19
|
+
currentDate?: Date;
|
|
20
|
+
locale?: string;
|
|
21
|
+
timeZone?: string;
|
|
22
|
+
firstDayOfWeek?: number;
|
|
23
|
+
enableEventModal?: boolean;
|
|
24
|
+
resources?: CalendarResource[];
|
|
25
|
+
resourceTimelineStartHour?: number;
|
|
26
|
+
resourceTimelineEndHour?: number;
|
|
27
|
+
class?: string;
|
|
28
|
+
onEventClick?: (event: CalendarEvent) => void;
|
|
29
|
+
onDateClick?: (date: Date) => void;
|
|
30
|
+
onAddEvent?: (newEvent: CalendarEvent) => void;
|
|
31
|
+
onEventReschedule?: (detail: {
|
|
32
|
+
event: CalendarEvent;
|
|
33
|
+
newStart: Date;
|
|
34
|
+
newEnd: Date;
|
|
35
|
+
}) => void;
|
|
36
|
+
onEventResize?: (detail: {
|
|
37
|
+
event: CalendarEvent;
|
|
38
|
+
newStart: Date;
|
|
39
|
+
newEnd: Date;
|
|
40
|
+
}) => void;
|
|
41
|
+
onDragCreate?: (detail: {
|
|
42
|
+
start: Date;
|
|
43
|
+
end: Date;
|
|
44
|
+
}) => void;
|
|
45
|
+
};
|
|
46
|
+
declare const EventCalendar: import("svelte").Component<$$ComponentProps, {}, "events" | "view" | "currentDate">;
|
|
47
|
+
type EventCalendar = ReturnType<typeof EventCalendar>;
|
|
48
|
+
export default EventCalendar;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/svelte';
|
|
3
|
+
import EventCalendar from './EventCalendar.svelte';
|
|
4
|
+
describe('EventCalendar', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
vi.setSystemTime(new Date('2025-06-15T12:00:00'));
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers();
|
|
11
|
+
});
|
|
12
|
+
it('renders the calendar component with correct month title', () => {
|
|
13
|
+
render(EventCalendar);
|
|
14
|
+
// Title uses full month name: "MMMM yyyy"
|
|
15
|
+
expect(screen.getByText('June 2025')).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
it('displays day-of-week headers', () => {
|
|
18
|
+
render(EventCalendar);
|
|
19
|
+
expect(screen.getByText('Sun')).toBeInTheDocument();
|
|
20
|
+
expect(screen.getByText('Mon')).toBeInTheDocument();
|
|
21
|
+
expect(screen.getByText('Tue')).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByText('Wed')).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByText('Thu')).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByText('Fri')).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByText('Sat')).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
it('renders month view with day grid', () => {
|
|
28
|
+
render(EventCalendar);
|
|
29
|
+
// Should show days of June 2025. Use getAllByText for numbers that appear
|
|
30
|
+
// multiple times in the month grid (e.g. from previous/next month overflow).
|
|
31
|
+
expect(screen.getAllByText('15').length).toBeGreaterThanOrEqual(1);
|
|
32
|
+
expect(screen.getAllByText('30').length).toBeGreaterThanOrEqual(1);
|
|
33
|
+
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1);
|
|
34
|
+
});
|
|
35
|
+
it('renders events in the calendar', () => {
|
|
36
|
+
const events = [
|
|
37
|
+
{
|
|
38
|
+
id: '1',
|
|
39
|
+
title: 'Team Meeting',
|
|
40
|
+
start: new Date('2025-06-15T09:00:00'),
|
|
41
|
+
end: new Date('2025-06-15T10:00:00'),
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
render(EventCalendar, { props: { events } });
|
|
45
|
+
expect(screen.getByText('Team Meeting')).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
it('renders multiple events on the same day', () => {
|
|
48
|
+
const events = [
|
|
49
|
+
{
|
|
50
|
+
id: '1',
|
|
51
|
+
title: 'Morning Standup',
|
|
52
|
+
start: new Date('2025-06-15T09:00:00'),
|
|
53
|
+
end: new Date('2025-06-15T09:30:00'),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: '2',
|
|
57
|
+
title: 'Lunch Break',
|
|
58
|
+
start: new Date('2025-06-15T12:00:00'),
|
|
59
|
+
end: new Date('2025-06-15T13:00:00'),
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
render(EventCalendar, { props: { events } });
|
|
63
|
+
expect(screen.getByText('Morning Standup')).toBeInTheDocument();
|
|
64
|
+
expect(screen.getByText('Lunch Break')).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
it('navigates to the previous month', async () => {
|
|
67
|
+
render(EventCalendar);
|
|
68
|
+
// Prev button is a Button with ChevronLeft icon; find by role
|
|
69
|
+
const prevBtn = screen.getAllByRole('button')[0];
|
|
70
|
+
await fireEvent.click(prevBtn);
|
|
71
|
+
expect(screen.getByText('May 2025')).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
it('navigates to the next month', async () => {
|
|
74
|
+
render(EventCalendar);
|
|
75
|
+
// Next button is the third button (prev=0, today=1, next=2)
|
|
76
|
+
const nextBtn = screen.getAllByRole('button')[2];
|
|
77
|
+
await fireEvent.click(nextBtn);
|
|
78
|
+
expect(screen.getByText('July 2025')).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
it('navigates back to today', async () => {
|
|
81
|
+
render(EventCalendar);
|
|
82
|
+
// Navigate to next month first
|
|
83
|
+
const nextBtn = screen.getAllByRole('button')[2];
|
|
84
|
+
await fireEvent.click(nextBtn);
|
|
85
|
+
expect(screen.getByText('July 2025')).toBeInTheDocument();
|
|
86
|
+
// Click Today button (text content is "Today")
|
|
87
|
+
const todayBtn = screen.getByText('Today');
|
|
88
|
+
await fireEvent.click(todayBtn);
|
|
89
|
+
expect(screen.getByText('June 2025')).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
it('displays view switcher buttons', () => {
|
|
92
|
+
render(EventCalendar);
|
|
93
|
+
expect(screen.getByText('Month')).toBeInTheDocument();
|
|
94
|
+
expect(screen.getByText('Week')).toBeInTheDocument();
|
|
95
|
+
expect(screen.getByText('Day')).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
it('shows event with color indicator', () => {
|
|
98
|
+
const events = [
|
|
99
|
+
{
|
|
100
|
+
id: '1',
|
|
101
|
+
title: 'Important Call',
|
|
102
|
+
start: new Date('2025-06-15T14:00:00'),
|
|
103
|
+
end: new Date('2025-06-15T15:00:00'),
|
|
104
|
+
color: '#dc2626',
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
render(EventCalendar, { props: { events } });
|
|
108
|
+
expect(screen.getByText('Important Call')).toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
it('renders empty state without crashing', () => {
|
|
111
|
+
render(EventCalendar, { props: { events: [] } });
|
|
112
|
+
expect(screen.getByText('June 2025')).toBeInTheDocument();
|
|
113
|
+
});
|
|
114
|
+
it('renders all-day events (may appear in both grid and all-day header)', () => {
|
|
115
|
+
const events = [
|
|
116
|
+
{
|
|
117
|
+
id: '1',
|
|
118
|
+
title: 'Company Holiday',
|
|
119
|
+
start: new Date('2025-06-15T00:00:00'),
|
|
120
|
+
end: new Date('2025-06-16T00:00:00'),
|
|
121
|
+
allDay: true,
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
render(EventCalendar, { props: { events } });
|
|
125
|
+
// All-day events appear in both the month grid and the all-day section
|
|
126
|
+
const matches = screen.getAllByText('Company Holiday');
|
|
127
|
+
expect(matches.length).toBeGreaterThanOrEqual(1);
|
|
128
|
+
});
|
|
129
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
export { default as Calendar } from
|
|
2
|
-
export { default as DatePicker } from
|
|
3
|
-
export
|
|
1
|
+
export { default as Calendar } from "./Calendar.svelte";
|
|
2
|
+
export { default as DatePicker } from "./DatePicker.svelte";
|
|
3
|
+
export { default as DateRangePicker } from "./DateRangePicker.svelte";
|
|
4
|
+
export { default as EventCalendar } from "./EventCalendar.svelte";
|
|
5
|
+
export type { CalendarDate } from "./Calendar.svelte";
|
|
6
|
+
export type { DateRange } from "./DateRangePicker.svelte";
|
|
7
|
+
export type { CalendarEvent, CalendarView } from "./EventCalendar.svelte";
|
|
8
|
+
export { expandRecurringEvent, expandRecurringEvents } from "./recurrence";
|
|
9
|
+
export type { CalendarRecurrence, CalendarRecurrenceFrequency, CalendarResource, RecurringCalendarEvent, } from "./recurrence";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
export { default as Calendar } from
|
|
2
|
-
export { default as DatePicker } from
|
|
1
|
+
export { default as Calendar } from "./Calendar.svelte";
|
|
2
|
+
export { default as DatePicker } from "./DatePicker.svelte";
|
|
3
|
+
export { default as DateRangePicker } from "./DateRangePicker.svelte";
|
|
4
|
+
export { default as EventCalendar } from "./EventCalendar.svelte";
|
|
5
|
+
export { expandRecurringEvent, expandRecurringEvents } from "./recurrence";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CalendarEvent } from "./EventCalendar";
|
|
2
|
+
/** Supported recurrence frequencies. This is intentionally smaller than RFC 5545 RRULE. */
|
|
3
|
+
export type CalendarRecurrenceFrequency = "daily" | "weekly" | "monthly" | "yearly";
|
|
4
|
+
/**
|
|
5
|
+
* Minimal RRULE-like recurrence model.
|
|
6
|
+
*
|
|
7
|
+
* Only frequency, interval, until, and count are supported. BYDAY, BYMONTHDAY,
|
|
8
|
+
* EXDATE, timezone transition rules, and other RFC 5545 semantics are not
|
|
9
|
+
* implemented. `until` is inclusive and `count` counts generated occurrences.
|
|
10
|
+
*/
|
|
11
|
+
export type CalendarRecurrence = {
|
|
12
|
+
frequency: CalendarRecurrenceFrequency;
|
|
13
|
+
interval?: number;
|
|
14
|
+
until?: Date;
|
|
15
|
+
count?: number;
|
|
16
|
+
};
|
|
17
|
+
/** Resource displayed as a row in `resourceTimeline` view. */
|
|
18
|
+
export type CalendarResource = {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
color?: string;
|
|
23
|
+
};
|
|
24
|
+
export type RecurringCalendarEvent = CalendarEvent & {
|
|
25
|
+
recurrence?: CalendarRecurrence;
|
|
26
|
+
resourceId?: string;
|
|
27
|
+
};
|
|
28
|
+
/** Expand one event into occurrences intersecting inclusive range. */
|
|
29
|
+
export declare function expandRecurringEvent(event: RecurringCalendarEvent, rangeStart: Date, rangeEnd: Date): CalendarEvent[];
|
|
30
|
+
/** Expand recurring events for a calendar viewport. Non-recurring events pass through. */
|
|
31
|
+
export declare function expandRecurringEvents(events: RecurringCalendarEvent[], rangeStart: Date, rangeEnd: Date): CalendarEvent[];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
function addOccurrence(date, recurrence) {
|
|
2
|
+
const result = new Date(date);
|
|
3
|
+
const interval = Math.max(1, Math.floor(recurrence.interval ?? 1));
|
|
4
|
+
if (recurrence.frequency === "daily")
|
|
5
|
+
result.setDate(result.getDate() + interval);
|
|
6
|
+
else if (recurrence.frequency === "weekly")
|
|
7
|
+
result.setDate(result.getDate() + interval * 7);
|
|
8
|
+
else if (recurrence.frequency === "monthly")
|
|
9
|
+
result.setMonth(result.getMonth() + interval);
|
|
10
|
+
else
|
|
11
|
+
result.setFullYear(result.getFullYear() + interval);
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
/** Expand one event into occurrences intersecting inclusive range. */
|
|
15
|
+
export function expandRecurringEvent(event, rangeStart, rangeEnd) {
|
|
16
|
+
if (!event.recurrence)
|
|
17
|
+
return [event];
|
|
18
|
+
const recurrence = event.recurrence;
|
|
19
|
+
const duration = new Date(event.end).getTime() - new Date(event.start).getTime();
|
|
20
|
+
const until = recurrence.until
|
|
21
|
+
? new Date(recurrence.until).getTime()
|
|
22
|
+
: Infinity;
|
|
23
|
+
const maxCount = recurrence.count === undefined
|
|
24
|
+
? Infinity
|
|
25
|
+
: Math.max(0, Math.floor(recurrence.count));
|
|
26
|
+
const occurrences = [];
|
|
27
|
+
let occurrenceStart = new Date(event.start);
|
|
28
|
+
let index = 0;
|
|
29
|
+
// Guard malformed input while keeping expansion deterministic for UI rendering.
|
|
30
|
+
while (index < maxCount && occurrenceStart.getTime() <= until) {
|
|
31
|
+
const occurrenceEnd = new Date(occurrenceStart.getTime() + duration);
|
|
32
|
+
if (occurrenceEnd.getTime() >= rangeStart.getTime() &&
|
|
33
|
+
occurrenceStart.getTime() <= rangeEnd.getTime()) {
|
|
34
|
+
occurrences.push({
|
|
35
|
+
...event,
|
|
36
|
+
id: index === 0 ? event.id : `${event.id}::${occurrenceStart.getTime()}`,
|
|
37
|
+
start: occurrenceStart,
|
|
38
|
+
end: occurrenceEnd,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
index += 1;
|
|
42
|
+
const nextStart = addOccurrence(occurrenceStart, recurrence);
|
|
43
|
+
if (nextStart.getTime() <= occurrenceStart.getTime())
|
|
44
|
+
break;
|
|
45
|
+
occurrenceStart = nextStart;
|
|
46
|
+
// No need to iterate unbounded future occurrences after range end.
|
|
47
|
+
if (occurrenceStart.getTime() > rangeEnd.getTime() &&
|
|
48
|
+
!recurrence.until &&
|
|
49
|
+
recurrence.count === undefined)
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
return occurrences;
|
|
53
|
+
}
|
|
54
|
+
/** Expand recurring events for a calendar viewport. Non-recurring events pass through. */
|
|
55
|
+
export function expandRecurringEvents(events, rangeStart, rangeEnd) {
|
|
56
|
+
return events.flatMap((event) => expandRecurringEvent(event, rangeStart, rangeEnd));
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intinyagroup/calendar",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Calendar and DatePicker components for intinyagroup UI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
|
-
"svelte": "./dist/index.js"
|
|
9
|
+
"svelte": "./dist/index.js",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"default": "./dist/index.js"
|
|
10
12
|
}
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
@@ -16,16 +18,19 @@
|
|
|
16
18
|
"scripts": {
|
|
17
19
|
"build": "svelte-package -o dist",
|
|
18
20
|
"check": "echo 'no check'",
|
|
19
|
-
"dev": "svelte-package -o dist -w"
|
|
21
|
+
"dev": "svelte-package -o dist -w",
|
|
22
|
+
"test": "vitest run"
|
|
20
23
|
},
|
|
21
24
|
"dependencies": {
|
|
22
|
-
"@intinyagroup/ui": "0.
|
|
25
|
+
"@intinyagroup/ui": "0.2.7",
|
|
26
|
+
"date-fns": "^4.4.0",
|
|
27
|
+
"lucide-svelte": "^1.0.1"
|
|
23
28
|
},
|
|
24
29
|
"devDependencies": {
|
|
25
30
|
"@sveltejs/package": "^2.5.8",
|
|
31
|
+
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
|
26
32
|
"svelte": "^5.55.2",
|
|
27
|
-
"typescript": "^6.0.2"
|
|
28
|
-
"@sveltejs/vite-plugin-svelte": "^5.1.1"
|
|
33
|
+
"typescript": "^6.0.2"
|
|
29
34
|
},
|
|
30
35
|
"peerDependencies": {
|
|
31
36
|
"svelte": "^5.0.0"
|