diff --git a/package-lock.json b/package-lock.json index 39d711b..aaa5f16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "chart.js": "^4.5.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "highlight.js": "^11.11.1", "i18next": "^23.11.5", "i18next-browser-languagedetector": "^7.2.1", @@ -2695,6 +2696,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 4154a22..a98c3f9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "chart.js": "^4.5.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "highlight.js": "^11.11.1", "i18next": "^23.11.5", "i18next-browser-languagedetector": "^7.2.1", diff --git a/src/app/components/cards/CalendarCard.jsx b/src/app/components/cards/CalendarCard.jsx new file mode 100644 index 0000000..6fb9330 --- /dev/null +++ b/src/app/components/cards/CalendarCard.jsx @@ -0,0 +1,183 @@ +'use client'; + +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday, isSameMonth } from 'date-fns'; + +const CalendarCard = ({ events = [], birthdays = [], meetings = [], className = '' }) => { + const [currentDate, setCurrentDate] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(null); + + // Generate days for the current month + const monthStart = startOfMonth(currentDate); + const monthEnd = endOfMonth(currentDate); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + // Function to get all events for a specific date + const getDateEvents = (date) => { + const formattedDate = format(date, 'yyyy-MM-dd'); + return { + events: events.filter(event => format(new Date(event.date), 'yyyy-MM-dd') === formattedDate), + birthdays: birthdays.filter(birthday => format(new Date(birthday.date), 'yyyy-MM-dd') === formattedDate), + meetings: meetings.filter(meeting => format(new Date(meeting.date), 'yyyy-MM-dd') === formattedDate) + }; + }; + + // Function to check if a date has any events + const getDateHighlights = (date) => { + const { events: dateEvents, birthdays: dateBirthdays, meetings: dateMeetings } = getDateEvents(date); + return { + hasEvent: dateEvents.length > 0, + hasBirthday: dateBirthdays.length > 0, + hasMeeting: dateMeetings.length > 0 + }; + }; + + // Function to handle month navigation + const changeMonth = (increment) => { + setCurrentDate(prev => new Date(prev.getFullYear(), prev.getMonth() + increment, 1)); + }; + + return ( +
+ {/* Calendar Header */} +
+ + +

+ {format(currentDate, 'MMMM yyyy')} +

+ + +
+ + {/* Weekday Headers */} +
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => ( +
+ {day} +
+ ))} +
+ + {/* Calendar Grid */} +
+ {days.map((day) => { + const { hasEvent, hasBirthday, hasMeeting } = getDateHighlights(day); + const isCurrentMonth = isSameMonth(day, currentDate); + + return ( +
setSelectedDate(day)} + className={` + group relative p-2 h-14 border border-gray-200 dark:border-gray-700 rounded + ${isCurrentMonth ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-900'} + ${isToday(day) ? 'ring-2 ring-blue-500' : ''} + ${selectedDate && format(day, 'yyyy-MM-dd') === format(selectedDate, 'yyyy-MM-dd') + ? 'bg-blue-50 dark:bg-blue-900' : ''} + cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors + `} + > + + {format(day, 'd')} + + + {/* Highlight Indicators */} +
+ {hasEvent && ( +
+ )} + {hasBirthday && ( +
+ )} + {hasMeeting && ( +
+ )} +
+ + {/* Tooltip */} + {(hasEvent || hasBirthday || hasMeeting) && ( +
+ {getDateEvents(day).events.map((event, idx) => ( +
+
+ {event.title} +
+ ))} + {getDateEvents(day).birthdays.map((birthday, idx) => ( +
+
+ {birthday.name}'s Birthday +
+ ))} + {getDateEvents(day).meetings.map((meeting, idx) => ( +
+
+ {meeting.title} +
+ ))} +
+
+ )} +
+ ); + })} +
+ + {/* Legend */} +
+
+
+ Event +
+
+
+ Birthday +
+
+
+ Meeting +
+
+
+ ); +}; + +CalendarCard.propTypes = { + events: PropTypes.arrayOf(PropTypes.shape({ + date: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]).isRequired, + title: PropTypes.string.isRequired + })), + birthdays: PropTypes.arrayOf(PropTypes.shape({ + date: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]).isRequired, + name: PropTypes.string.isRequired + })), + meetings: PropTypes.arrayOf(PropTypes.shape({ + date: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]).isRequired, + title: PropTypes.string.isRequired + })), + className: PropTypes.string +}; + +export default CalendarCard; \ No newline at end of file diff --git a/src/app/components/cards/CalendarCard.test.jsx b/src/app/components/cards/CalendarCard.test.jsx new file mode 100644 index 0000000..ca6e030 --- /dev/null +++ b/src/app/components/cards/CalendarCard.test.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import CalendarCard from './CalendarCard'; + +describe('CalendarCard', () => { + const mockEvents = [ + { date: '2025-10-30', title: 'Test Event' } + ]; + const mockBirthdays = [ + { date: '2025-10-31', name: 'John Doe' } + ]; + const mockMeetings = [ + { date: '2025-10-29', title: 'Team Meeting' } + ]; + + it('renders calendar with current month and year', () => { + render(); + expect(screen.getByText('October 2025')).toBeInTheDocument(); + }); + + it('shows event indicators when events are provided', () => { + render( + + ); + + // Check for legend items + expect(screen.getByText('Event')).toBeInTheDocument(); + expect(screen.getByText('Birthday')).toBeInTheDocument(); + expect(screen.getByText('Meeting')).toBeInTheDocument(); + }); + + it('navigates between months when clicking navigation buttons', () => { + render(); + + // Initial month + expect(screen.getByText('October 2025')).toBeInTheDocument(); + + // Click next month button + fireEvent.click(screen.getByLabelText('Next month')); + expect(screen.getByText('November 2025')).toBeInTheDocument(); + + // Click previous month button + fireEvent.click(screen.getByLabelText('Previous month')); + expect(screen.getByText('October 2025')).toBeInTheDocument(); + }); + + it('applies custom className when provided', () => { + const customClass = 'custom-calendar'; + const { container } = render(); + expect(container.firstChild).toHaveClass(customClass); + }); + + it('renders all days of the current month', () => { + render(); + // Check if weekday headers are present + const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + weekdays.forEach(day => { + expect(screen.getByText(day)).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/src/app/components/page.jsx b/src/app/components/page.jsx index 2d55af9..4dc3ddf 100644 --- a/src/app/components/page.jsx +++ b/src/app/components/page.jsx @@ -28,6 +28,7 @@ import PricingCard from "./cards/PricingCard"; import DataCard from "./cards/DataCard"; import SmartCard from "./cards/SmartCard"; import UserCard from "@/app/components/cards/UserCard"; +import CalendarCard from "./cards/CalendarCard"; // Inputs import TextInput from "./inputs/TextInput"; @@ -239,6 +240,30 @@ export default function Page() { }, ], cards: [ + { + name: "Calendar Card", + component: ( + + ), + keywords: ["calendar", "date", "events", "meetings", "birthdays"], + }, { name: t('cards.simple.name'), component: (