├── .eslintrc.json
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── components.json
├── next.config.mjs
├── package.json
├── postcss.config.mjs
├── public
├── preview_1.png
├── preview_2.png
├── preview_3.png
├── preview_4.png
└── preview_5.png
├── src
├── app
│ ├── (calendar)
│ │ ├── agenda-view
│ │ │ └── page.tsx
│ │ ├── day-view
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── month-view
│ │ │ └── page.tsx
│ │ ├── week-view
│ │ │ └── page.tsx
│ │ └── year-view
│ │ │ └── page.tsx
│ ├── favicon.ico
│ └── layout.tsx
├── calendar
│ ├── components
│ │ ├── agenda-view
│ │ │ ├── agenda-day-group.tsx
│ │ │ ├── agenda-event-card.tsx
│ │ │ └── calendar-agenda-view.tsx
│ │ ├── change-badge-variant-input.tsx
│ │ ├── change-visible-hours-input.tsx
│ │ ├── change-working-hours-input.tsx
│ │ ├── client-container.tsx
│ │ ├── dialogs
│ │ │ ├── add-event-dialog.tsx
│ │ │ ├── edit-event-dialog.tsx
│ │ │ └── event-details-dialog.tsx
│ │ ├── dnd
│ │ │ ├── dnd-provider.tsx
│ │ │ ├── draggable-event.tsx
│ │ │ ├── droppable-day-cell.tsx
│ │ │ └── droppable-time-block.tsx
│ │ ├── header
│ │ │ ├── calendar-header.tsx
│ │ │ ├── date-navigator.tsx
│ │ │ ├── today-button.tsx
│ │ │ └── user-select.tsx
│ │ ├── month-view
│ │ │ ├── calendar-month-view.tsx
│ │ │ ├── day-cell.tsx
│ │ │ ├── event-bullet.tsx
│ │ │ └── month-event-badge.tsx
│ │ ├── week-and-day-view
│ │ │ ├── calendar-day-view.tsx
│ │ │ ├── calendar-time-line.tsx
│ │ │ ├── calendar-week-view.tsx
│ │ │ ├── day-view-multi-day-events-row.tsx
│ │ │ ├── event-block.tsx
│ │ │ └── week-view-multi-day-events-row.tsx
│ │ └── year-view
│ │ │ ├── calendar-year-view.tsx
│ │ │ ├── year-view-day-cell.tsx
│ │ │ └── year-view-month.tsx
│ ├── contexts
│ │ └── calendar-context.tsx
│ ├── helpers.ts
│ ├── hooks
│ │ └── use-update-event.ts
│ ├── interfaces.ts
│ ├── mocks.ts
│ ├── requests.ts
│ ├── schemas.ts
│ └── types.ts
├── components
│ ├── layout
│ │ ├── change-theme.tsx
│ │ └── header.tsx
│ └── ui
│ │ ├── accordion.tsx
│ │ ├── avatar-group.tsx
│ │ ├── avatar.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── dialog.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── popover.tsx
│ │ ├── scroll-area.tsx
│ │ ├── select.tsx
│ │ ├── single-calendar.tsx
│ │ ├── single-day-picker.tsx
│ │ ├── skeleton.tsx
│ │ ├── switch.tsx
│ │ ├── textarea.tsx
│ │ ├── time-input.tsx
│ │ └── tooltip.tsx
├── constants
│ ├── cookies.const.ts
│ └── theme.const.ts
├── cookies
│ ├── get.ts
│ └── set.ts
├── hooks
│ └── use-disclosure.ts
├── lib
│ └── utils.ts
├── styles
│ ├── fonts.ts
│ └── globals.css
└── types.ts
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "plugin:tailwindcss/recommended"],
3 | "parser": "@typescript-eslint/parser",
4 | "plugins": ["@typescript-eslint", "tailwindcss"],
5 | "rules": {
6 | // Warn when console statements are used
7 | "no-console": "warn",
8 |
9 | // Enforce the use of the `import type` syntax for type imports
10 | "@typescript-eslint/consistent-type-imports": "error",
11 |
12 | // Disallow unused variables (with some exceptions)
13 | "@typescript-eslint/no-unused-vars": [
14 | "warn",
15 | {
16 | "vars": "all",
17 | "args": "after-used",
18 | "ignoreRestSiblings": false,
19 | "argsIgnorePattern": "^_",
20 | "varsIgnorePattern": "^_",
21 | "caughtErrorsIgnorePattern": "^_"
22 | }
23 | ],
24 |
25 | // Prevent usage of dangerous JSX props
26 | "react/no-danger": "error",
27 |
28 | // Prevent multiple component definitions in a single file, except in /src/components
29 | "react/no-multi-comp": ["error"],
30 |
31 | // Enforce consistent usage of destructuring assignment of props, state, and context
32 | "react/destructuring-assignment": ["error", "always"],
33 |
34 | // Enforce consistent React fragment syntax
35 | "react/jsx-fragments": ["error", "syntax"],
36 |
37 | // Prevent usage of unsafe `target='_blank'` without `rel="noopener noreferrer"`
38 | "react/jsx-no-target-blank": "error",
39 |
40 | // Enforce PascalCase for user-defined JSX components
41 | "react/jsx-pascal-case": "error",
42 |
43 | // Disallow unnecessary curly braces in JSX props and children
44 | "react/jsx-curly-brace-presence": ["error", { "props": "never", "children": "never" }],
45 |
46 | // Enforce a specific function type for function components
47 | "react/function-component-definition": [
48 | "error",
49 | {
50 | "namedComponents": "function-declaration",
51 | "unnamedComponents": "arrow-function"
52 | }
53 | ],
54 |
55 | // Ensure consistent use of file extension within the import path
56 | "import/extensions": [
57 | "error",
58 | "ignorePackages",
59 | {
60 | "ts": "never",
61 | "tsx": "never",
62 | "js": "never",
63 | "jsx": "never"
64 | }
65 | ],
66 |
67 | // Enforce using `@/` alias for imports from the `src/` directory
68 | "no-restricted-imports": [
69 | "error",
70 | {
71 | "patterns": [
72 | {
73 | "group": ["../*", "./*"],
74 | "message": "Usage of relative parent imports is not allowed. Use `@/` alias instead."
75 | }
76 | ]
77 | }
78 | ],
79 |
80 | // Enforce a consistent order for Tailwind CSS classes
81 | "tailwindcss/classnames-order": "warn",
82 |
83 | // Prevent using custom class names that are not defined in Tailwind CSS config
84 | "tailwindcss/no-custom-classname": [
85 | "warn",
86 | {
87 | "callees": ["cn", "clsx", "twMerge", "cva"],
88 | "whitelist": ["^theme-", "^chart-", "^event-dot"]
89 | }
90 | ],
91 |
92 | // Prevent using contradicting Tailwind CSS classes together
93 | "tailwindcss/no-contradicting-classname": "error"
94 | },
95 | "overrides": [
96 | {
97 | "files": ["src/components/**/*.{js,jsx,ts,tsx}"],
98 | "rules": {
99 | "react/no-multi-comp": "off"
100 | }
101 | }
102 | ]
103 | }
104 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 |
38 | # env
39 | .env
40 |
41 | # yarn
42 | yarn.lock
43 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-tailwindcss"],
3 | "tabWidth": 2,
4 | "semi": true,
5 | "singleQuote": false,
6 | "trailingComma": "es5",
7 | "printWidth": 160,
8 | "arrowParens": "avoid",
9 | "endOfLine": "auto"
10 | }
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Leonardo Ramos
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Big Calendar
2 |
3 | A feature-rich calendar application built with Next.js, TypeScript, and Tailwind CSS. This project provides a modern, responsive interface for managing events and schedules with multiple viewing options.
4 |
5 |
6 |
7 |
8 |
9 | ## Preview
10 |
11 | 
12 | 
13 | 
14 | 
15 | 
16 |
17 | ## Features
18 |
19 | - 📅 Multiple calendar views:
20 |
21 | - Agenda view
22 | - Year view
23 | - Month view
24 | - Week view with detailed time slots
25 | - Day view with hourly breakdown
26 |
27 | - 🎨 Event customization:
28 |
29 | - Multiple color options for events
30 | - Three badge display variants (dot, colored and mixed)
31 | - Support for single and multi-day events
32 |
33 | - 🔄 Drag and Drop:
34 |
35 | - Easily reschedule events by dragging and dropping
36 | - Move events between days in month view
37 | - Adjust event timing in week/day views
38 | - Visual feedback during dragging operations
39 |
40 | - 👥 User management:
41 |
42 | - Filter events by user
43 | - View all users's events simultaneously
44 | - User avatars and profile integration
45 |
46 | - ⚡ Real-time features:
47 |
48 | - Live time indicator
49 | - Current event highlighting
50 | - Dynamic event positioning
51 |
52 | - ⏰ Time customization:
53 |
54 | - Configurable working hours with distinct styling
55 | - Adjustable visible hours range
56 | - Focus on relevant time periods
57 |
58 | - 🎯 UI/UX features:
59 | - Responsive design for all screen sizes
60 | - Intuitive navigation between dates
61 | - Clean and modern interface
62 | - Dark mode support
63 |
64 | ## Tech stack
65 |
66 | - **Framework**: Next.js 14
67 | - **Language**: TypeScript
68 | - **Styling**: Tailwind v3
69 | - **Date Management**: date-fns
70 | - **UI Components**: shadcn/ui
71 | - **State Management**: React Context
72 |
73 | ## Getting started
74 |
75 | 1. Clone the repository:
76 |
77 | ```bash
78 | git clone https://github.com/yourusername/calendar-app.git
79 | cd calendar-app
80 | ```
81 |
82 | 2. Install dependencies:
83 |
84 | ```bash
85 | npm install
86 | ```
87 |
88 | 3. Start the development server:
89 |
90 | ```bash
91 | npm run dev
92 | ```
93 |
94 | or
95 |
96 | ```bash
97 | npm run turbo
98 | ```
99 |
100 | 4. Open your browser and navigate to `http://localhost:3000` to view the application.
101 |
102 | ## Project structure
103 |
104 | The project structure is organized as follows:
105 |
106 | ```
107 | src/
108 | ├── app/
109 | ├── calendar/ # All files related to calendar are in this folder
110 | │ ├── components/
111 | │ │ ├── agenda-view/ # Agenda view components
112 | │ │ ├── dialogs/ # Dialogs components
113 | │ │ ├── dnd/ # Drag and drop components
114 | │ │ ├── header/ # Calendar header components
115 | │ │ ├── month-view/ # Month view components
116 | │ │ ├── week-and-day-view/ # Week and day view components
117 | │ │ └── year-view/ # Year view components
118 | │ ├── contexts/ # Calendar context and state management
119 | │ ├── helpers/ # Utility functions
120 | │ ├── interfaces/ # TypeScript interfaces
121 | │ └── types/ # TypeScript types
122 | └── components/ # Components not related to calendar eg: ui and layout components
123 | ```
124 |
125 | ## How to implement in your project
126 |
127 | ### Installation
128 |
129 | 1. Copy the required folders to your project:
130 |
131 | ```
132 | src/calendar/ # Core calendar functionality
133 | src/components/ui/ # UI components used by the calendar
134 | src/hooks/ # Required hooks like use-disclosure
135 | ```
136 |
137 | 2. Install dependencies missing in your project
138 |
139 | ### Basic setup
140 |
141 | 1. **Set up the `CalendarProvider`**
142 |
143 | Wrap your application or page with the `CalendarProvider`:
144 |
145 | ```tsx
146 | import { CalendarProvider } from "@/calendar/contexts/calendar-context";
147 |
148 | // Fetch your events and users data
149 | const events = await getEvents();
150 | const users = await getUsers();
151 |
152 | export default function Layout({ children }) {
153 | return (
154 |
155 | {children}
156 |
157 | );
158 | }
159 | ```
160 |
161 | 2. **Add a `CalendarView`**
162 |
163 | Use the `ClientContainer` to render a specific view:
164 |
165 | ```tsx
166 | import { ClientContainer } from "@/calendar/components/client-container";
167 |
168 | export default function CalendarPage() {
169 | return ;
170 | }
171 | ```
172 |
173 | ### Views configuration
174 |
175 | The calendar supports five different views, each can be used with the `ClientContainer` component:
176 |
177 | ```tsx
178 | // Day view
179 |
180 |
181 | // Week view
182 |
183 |
184 | // Month view
185 |
186 |
187 | // Year view
188 |
189 |
190 | // Agenda view
191 |
192 | ```
193 |
194 | ### Data structure
195 |
196 | 1. **Events Format**
197 |
198 | Events should follow this interface (you can modify it as you want, but the calendar will expect these fields):
199 |
200 | ```tsx
201 | interface IEvent {
202 | id: string;
203 | title: string;
204 | description: string;
205 | startDate: string; // ISO string
206 | endDate: string; // ISO string
207 | color: "blue" | "green" | "red" | "yellow" | "purple" | "orange";
208 | user: {
209 | id: string;
210 | name: string;
211 | };
212 | }
213 | ```
214 |
215 | 2. **Users format**
216 |
217 | Users should follow this interface (you can modify it as you want, but the calendar will expect these fields):
218 |
219 | ```tsx
220 | interface IUser {
221 | id: string;
222 | name: string;
223 | picturePath?: string; // Optional avatar image
224 | }
225 | ```
226 |
227 | ### Customizing the calendar
228 |
229 | 1. **Badge Variants**
230 |
231 | You can control the event display style with the `ChangeBadgeVariantInput` component:
232 |
233 | ```tsx
234 | import { ChangeBadgeVariantInput } from "@/calendar/components/change-badge-variant-input";
235 |
236 | // Place this anywhere in your project tree inside the CalendarProvider
237 | ;
238 | ```
239 |
240 | 2. **Creating events**
241 |
242 | Implement your own event creation by modifying the `onSubmit` handler in the `AddEventDialog` component.
243 |
244 | ### Using the Calendar Context
245 |
246 | You can access and control the calendar state from any component using the `useCalendar` hook:
247 |
248 | ```tsx
249 | import { useCalendar } from "@/calendar/contexts/calendar-context";
250 |
251 | function MyComponent() {
252 | const { selectedDate, setSelectedDate, selectedUserId, setSelectedUserId, events, users, badgeVariant, setBadgeVariant } = useCalendar();
253 |
254 | // Your component logic
255 | }
256 | ```
257 |
258 | ### Example implementation
259 |
260 | ```tsx
261 | // pages/calendar.tsx
262 | import { CalendarProvider } from "@/calendar/contexts/calendar-context";
263 | import { ClientContainer } from "@/calendar/components/client-container";
264 | import { ChangeBadgeVariantInput } from "@/calendar/components/change-badge-variant-input";
265 |
266 | export default function CalendarPage({ events, users }) {
267 | return (
268 |
269 |
270 |
271 |
272 |
273 |
274 | );
275 | }
276 | ```
277 |
278 | ## Contributing
279 |
280 | Contributions are welcome! Please feel free to submit a Pull Request.
281 |
282 |
283 | Made by Leonardo Ramos 👋 Get in touch!
284 |
285 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/styles/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: { unoptimized: true },
4 | redirects: async () => [
5 | {
6 | source: "/",
7 | destination: "/month-view",
8 | permanent: false,
9 | },
10 | ],
11 | };
12 |
13 | export default nextConfig;
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "big-calendar",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "turbo": "next dev --turbo"
11 | },
12 | "dependencies": {
13 | "@hookform/resolvers": "^5.0.1",
14 | "@radix-ui/react-accordion": "^1.2.7",
15 | "@radix-ui/react-avatar": "^1.1.6",
16 | "@radix-ui/react-dialog": "^1.1.10",
17 | "@radix-ui/react-label": "^2.1.4",
18 | "@radix-ui/react-popover": "^1.1.10",
19 | "@radix-ui/react-scroll-area": "^1.2.5",
20 | "@radix-ui/react-select": "^2.2.2",
21 | "@radix-ui/react-separator": "^1.1.4",
22 | "@radix-ui/react-slot": "^1.2.0",
23 | "@radix-ui/react-switch": "^1.2.2",
24 | "@radix-ui/react-tooltip": "^1.2.3",
25 | "@vercel/analytics": "^1.5.0",
26 | "class-variance-authority": "^0.7.1",
27 | "clsx": "^2.1.1",
28 | "date-fns": "^3",
29 | "lucide-react": "^0.488.0",
30 | "next": "^14.2.3",
31 | "react": "18.2.0",
32 | "react-aria-components": "^1.6.0",
33 | "react-day-picker": "8.10.1",
34 | "react-dnd": "^16.0.1",
35 | "react-dnd-html5-backend": "^16.0.1",
36 | "react-dom": "18.2.0",
37 | "react-hook-form": "^7.55.0",
38 | "tailwind-merge": "^3.2.0",
39 | "tailwindcss-animate": "^1.0.7",
40 | "zod": "^3.24.2"
41 | },
42 | "devDependencies": {
43 | "@types/node": "^20.5.7",
44 | "@types/react": "^18.2.21",
45 | "@types/react-dom": "^18.2.7",
46 | "@typescript-eslint/eslint-plugin": "^8.15.0",
47 | "@typescript-eslint/parser": "^8.15.0",
48 | "eslint": "^8.48.0",
49 | "eslint-config-next": "^15.0.3",
50 | "eslint-plugin-tailwindcss": "^3.17.5",
51 | "postcss": "^8.4.49",
52 | "prettier": "^3.3.3",
53 | "prettier-plugin-tailwindcss": "^0.6.8",
54 | "tailwindcss": "^3.4.15",
55 | "typescript": "^5.6.3"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/preview_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lramos33/big-calendar/c3c634fa9efa89e118878b320560ac8fba015f33/public/preview_1.png
--------------------------------------------------------------------------------
/public/preview_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lramos33/big-calendar/c3c634fa9efa89e118878b320560ac8fba015f33/public/preview_2.png
--------------------------------------------------------------------------------
/public/preview_3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lramos33/big-calendar/c3c634fa9efa89e118878b320560ac8fba015f33/public/preview_3.png
--------------------------------------------------------------------------------
/public/preview_4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lramos33/big-calendar/c3c634fa9efa89e118878b320560ac8fba015f33/public/preview_4.png
--------------------------------------------------------------------------------
/public/preview_5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lramos33/big-calendar/c3c634fa9efa89e118878b320560ac8fba015f33/public/preview_5.png
--------------------------------------------------------------------------------
/src/app/(calendar)/agenda-view/page.tsx:
--------------------------------------------------------------------------------
1 | import { ClientContainer } from "@/calendar/components/client-container";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/(calendar)/day-view/page.tsx:
--------------------------------------------------------------------------------
1 | import { ClientContainer } from "@/calendar/components/client-container";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/(calendar)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Settings } from "lucide-react";
2 |
3 | import { CalendarProvider } from "@/calendar/contexts/calendar-context";
4 |
5 | import { ChangeBadgeVariantInput } from "@/calendar/components/change-badge-variant-input";
6 | import { ChangeVisibleHoursInput } from "@/calendar/components/change-visible-hours-input";
7 | import { ChangeWorkingHoursInput } from "@/calendar/components/change-working-hours-input";
8 | import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
9 |
10 | import { getEvents, getUsers } from "@/calendar/requests";
11 |
12 | export default async function Layout({ children }: { children: React.ReactNode }) {
13 | const [events, users] = await Promise.all([getEvents(), getUsers()]);
14 |
15 | return (
16 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
Calendar settings
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/(calendar)/month-view/page.tsx:
--------------------------------------------------------------------------------
1 | import { ClientContainer } from "@/calendar/components/client-container";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/(calendar)/week-view/page.tsx:
--------------------------------------------------------------------------------
1 | import { ClientContainer } from "@/calendar/components/client-container";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/(calendar)/year-view/page.tsx:
--------------------------------------------------------------------------------
1 | import { ClientContainer } from "@/calendar/components/client-container";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lramos33/big-calendar/c3c634fa9efa89e118878b320560ac8fba015f33/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "@/styles/globals.css";
2 |
3 | import { Analytics } from "@vercel/analytics/react";
4 |
5 | import { inter } from "@/styles/fonts";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | import { Header } from "@/components/layout/header";
10 |
11 | import { getTheme } from "@/cookies/get";
12 |
13 | import type { Metadata, Viewport } from "next";
14 |
15 | export const viewport: Viewport = {
16 | width: "device-width",
17 | initialScale: 1,
18 | maximumScale: 1,
19 | };
20 |
21 | export const metadata: Metadata = {
22 | title: "Big Calendar by lramos33",
23 | description:
24 | "A feature-rich calendar application built with Next.js, TypeScript, and Tailwind CSS. This project provides a modern, responsive interface for managing events and schedules with multiple viewing options.",
25 | };
26 |
27 | export default function Layout({ children }: { children: React.ReactNode }) {
28 | const theme = getTheme();
29 |
30 | return (
31 |
32 |
33 |
34 |
35 | {children}
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/calendar/components/agenda-view/agenda-day-group.tsx:
--------------------------------------------------------------------------------
1 | import { differenceInDays, format, parseISO, startOfDay } from "date-fns";
2 |
3 | import { AgendaEventCard } from "@/calendar/components/agenda-view/agenda-event-card";
4 |
5 | import type { IEvent } from "@/calendar/interfaces";
6 |
7 | interface IProps {
8 | date: Date;
9 | events: IEvent[];
10 | multiDayEvents: IEvent[];
11 | }
12 |
13 | export function AgendaDayGroup({ date, events, multiDayEvents }: IProps) {
14 | const sortedEvents = [...events].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
15 |
16 | return (
17 |
18 |
19 |
{format(date, "EEEE, MMMM d, yyyy")}
20 |
21 |
22 |
23 | {multiDayEvents.length > 0 &&
24 | multiDayEvents.map(event => {
25 | const eventStart = startOfDay(parseISO(event.startDate));
26 | const eventEnd = startOfDay(parseISO(event.endDate));
27 | const currentDate = startOfDay(date);
28 |
29 | const eventTotalDays = differenceInDays(eventEnd, eventStart) + 1;
30 | const eventCurrentDay = differenceInDays(currentDate, eventStart) + 1;
31 | return
;
32 | })}
33 |
34 | {sortedEvents.length > 0 && sortedEvents.map(event =>
)}
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/calendar/components/agenda-view/agenda-event-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { format, parseISO } from "date-fns";
4 | import { cva } from "class-variance-authority";
5 | import { Clock, Text, User } from "lucide-react";
6 |
7 | import { useCalendar } from "@/calendar/contexts/calendar-context";
8 |
9 | import { EventDetailsDialog } from "@/calendar/components/dialogs/event-details-dialog";
10 |
11 | import type { IEvent } from "@/calendar/interfaces";
12 | import type { VariantProps } from "class-variance-authority";
13 |
14 | const agendaEventCardVariants = cva(
15 | "flex select-none items-center justify-between gap-3 rounded-md border p-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
16 | {
17 | variants: {
18 | color: {
19 | // Colored variants
20 | blue: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300 [&_.event-dot]:fill-blue-600",
21 | green: "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300 [&_.event-dot]:fill-green-600",
22 | red: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300 [&_.event-dot]:fill-red-600",
23 | yellow: "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300 [&_.event-dot]:fill-yellow-600",
24 | purple: "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300 [&_.event-dot]:fill-purple-600",
25 | orange: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300 [&_.event-dot]:fill-orange-600",
26 | gray: "border-neutral-200 bg-neutral-50 text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 [&_.event-dot]:fill-neutral-600",
27 |
28 | // Dot variants
29 | "blue-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-blue-600",
30 | "green-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-green-600",
31 | "red-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-red-600",
32 | "orange-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-orange-600",
33 | "purple-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-purple-600",
34 | "yellow-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-yellow-600",
35 | "gray-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-neutral-600",
36 | },
37 | },
38 | defaultVariants: {
39 | color: "blue-dot",
40 | },
41 | }
42 | );
43 |
44 | interface IProps {
45 | event: IEvent;
46 | eventCurrentDay?: number;
47 | eventTotalDays?: number;
48 | }
49 |
50 | export function AgendaEventCard({ event, eventCurrentDay, eventTotalDays }: IProps) {
51 | const { badgeVariant } = useCalendar();
52 |
53 | const startDate = parseISO(event.startDate);
54 | const endDate = parseISO(event.endDate);
55 |
56 | const color = (badgeVariant === "dot" ? `${event.color}-dot` : event.color) as VariantProps["color"];
57 |
58 | const agendaEventCardClasses = agendaEventCardVariants({ color });
59 |
60 | const handleKeyDown = (e: React.KeyboardEvent) => {
61 | if (e.key === "Enter" || e.key === " ") {
62 | e.preventDefault();
63 | if (e.currentTarget instanceof HTMLElement) e.currentTarget.click();
64 | }
65 | };
66 |
67 | return (
68 |
69 |
70 |
71 |
72 | {["mixed", "dot"].includes(badgeVariant) && (
73 |
76 | )}
77 |
78 |
79 | {eventCurrentDay && eventTotalDays && (
80 |
81 | Day {eventCurrentDay} of {eventTotalDays} •{" "}
82 |
83 | )}
84 | {event.title}
85 |
86 |
87 |
88 |
89 |
90 |
{event.user.name}
91 |
92 |
93 |
94 |
95 |
96 | {format(startDate, "h:mm a")} - {format(endDate, "h:mm a")}
97 |
98 |
99 |
100 |
101 |
102 |
{event.description}
103 |
104 |
105 |
106 |
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/src/calendar/components/agenda-view/calendar-agenda-view.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { CalendarX2 } from "lucide-react";
3 | import { parseISO, format, endOfDay, startOfDay, isSameMonth } from "date-fns";
4 |
5 | import { useCalendar } from "@/calendar/contexts/calendar-context";
6 |
7 | import { ScrollArea } from "@/components/ui/scroll-area";
8 | import { AgendaDayGroup } from "@/calendar/components/agenda-view/agenda-day-group";
9 |
10 | import type { IEvent } from "@/calendar/interfaces";
11 |
12 | interface IProps {
13 | singleDayEvents: IEvent[];
14 | multiDayEvents: IEvent[];
15 | }
16 |
17 | export function CalendarAgendaView({ singleDayEvents, multiDayEvents }: IProps) {
18 | const { selectedDate } = useCalendar();
19 |
20 | const eventsByDay = useMemo(() => {
21 | const allDates = new Map();
22 |
23 | singleDayEvents.forEach(event => {
24 | const eventDate = parseISO(event.startDate);
25 | if (!isSameMonth(eventDate, selectedDate)) return;
26 |
27 | const dateKey = format(eventDate, "yyyy-MM-dd");
28 |
29 | if (!allDates.has(dateKey)) {
30 | allDates.set(dateKey, { date: startOfDay(eventDate), events: [], multiDayEvents: [] });
31 | }
32 |
33 | allDates.get(dateKey)?.events.push(event);
34 | });
35 |
36 | multiDayEvents.forEach(event => {
37 | const eventStart = parseISO(event.startDate);
38 | const eventEnd = parseISO(event.endDate);
39 |
40 | let currentDate = startOfDay(eventStart);
41 | const lastDate = endOfDay(eventEnd);
42 |
43 | while (currentDate <= lastDate) {
44 | if (isSameMonth(currentDate, selectedDate)) {
45 | const dateKey = format(currentDate, "yyyy-MM-dd");
46 |
47 | if (!allDates.has(dateKey)) {
48 | allDates.set(dateKey, { date: new Date(currentDate), events: [], multiDayEvents: [] });
49 | }
50 |
51 | allDates.get(dateKey)?.multiDayEvents.push(event);
52 | }
53 | currentDate = new Date(currentDate.setDate(currentDate.getDate() + 1));
54 | }
55 | });
56 |
57 | return Array.from(allDates.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
58 | }, [singleDayEvents, multiDayEvents, selectedDate]);
59 |
60 | const hasAnyEvents = singleDayEvents.length > 0 || multiDayEvents.length > 0;
61 |
62 | return (
63 |
64 |
65 |
66 | {eventsByDay.map(dayGroup => (
67 |
68 | ))}
69 |
70 | {!hasAnyEvents && (
71 |
72 |
73 |
No events scheduled for the selected month
74 |
75 | )}
76 |
77 |
78 |
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/src/calendar/components/change-badge-variant-input.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useCalendar } from "@/calendar/contexts/calendar-context";
4 |
5 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
6 |
7 | export function ChangeBadgeVariantInput() {
8 | const { badgeVariant, setBadgeVariant } = useCalendar();
9 |
10 | return (
11 |
12 |
Change badge variant
13 |
14 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/calendar/components/change-visible-hours-input.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { Info } from "lucide-react";
5 |
6 | import { useCalendar } from "@/calendar/contexts/calendar-context";
7 |
8 | import { Button } from "@/components/ui/button";
9 | import { TimeInput } from "@/components/ui/time-input";
10 | import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip";
11 |
12 | import type { TimeValue } from "react-aria-components";
13 |
14 | export function ChangeVisibleHoursInput() {
15 | const { visibleHours, setVisibleHours } = useCalendar();
16 |
17 | const [from, setFrom] = useState<{ hour: number; minute: number }>({ hour: visibleHours.from, minute: 0 });
18 | const [to, setTo] = useState<{ hour: number; minute: number }>({ hour: visibleHours.to, minute: 0 });
19 |
20 | const handleApply = () => {
21 | const toHour = to.hour === 0 ? 24 : to.hour;
22 | setVisibleHours({ from: from.hour, to: toHour });
23 | };
24 |
25 | return (
26 |
27 |
28 |
Change visible hours
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | If an event falls outside the specified visible hours, the visible hours will automatically adjust to include that event.
38 |
39 |
40 |
41 |
42 |
43 |
44 |
From
45 |
void} />
46 | To
47 | void} />
48 |
49 |
50 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/calendar/components/change-working-hours-input.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { Info, Moon } from "lucide-react";
5 | import { useCalendar } from "@/calendar/contexts/calendar-context";
6 |
7 | import { Button } from "@/components/ui/button";
8 | import { Switch } from "@/components/ui/switch";
9 | import { TimeInput } from "@/components/ui/time-input";
10 |
11 | import type { TimeValue } from "react-aria-components";
12 | import { TooltipContent } from "@/components/ui/tooltip";
13 | import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";
14 | import { TooltipProvider } from "@/components/ui/tooltip";
15 |
16 | const DAYS_OF_WEEK = [
17 | { index: 0, name: "Sunday" },
18 | { index: 1, name: "Monday" },
19 | { index: 2, name: "Tuesday" },
20 | { index: 3, name: "Wednesday" },
21 | { index: 4, name: "Thursday" },
22 | { index: 5, name: "Friday" },
23 | { index: 6, name: "Saturday" },
24 | ];
25 |
26 | export function ChangeWorkingHoursInput() {
27 | const { workingHours, setWorkingHours } = useCalendar();
28 |
29 | const [localWorkingHours, setLocalWorkingHours] = useState({ ...workingHours });
30 |
31 | const handleToggleDay = (dayId: number) => {
32 | setLocalWorkingHours(prev => ({
33 | ...prev,
34 | [dayId]: prev[dayId].from > 0 || prev[dayId].to > 0 ? { from: 0, to: 0 } : { from: 9, to: 17 },
35 | }));
36 | };
37 |
38 | const handleTimeChange = (dayId: number, timeType: "from" | "to", value: TimeValue | null) => {
39 | if (!value) return;
40 |
41 | setLocalWorkingHours(prev => {
42 | const updatedDay = { ...prev[dayId], [timeType]: value.hour };
43 | if (timeType === "to" && value.hour === 0 && updatedDay.from === 0) updatedDay.to = 24;
44 | return { ...prev, [dayId]: updatedDay };
45 | });
46 | };
47 |
48 | const handleSave = () => {
49 | const updatedWorkingHours = { ...localWorkingHours };
50 |
51 | for (const dayId in updatedWorkingHours) {
52 | const day = updatedWorkingHours[parseInt(dayId)];
53 | const isDayActive = localWorkingHours[parseInt(dayId)].from > 0 || localWorkingHours[parseInt(dayId)].to > 0;
54 |
55 | if (isDayActive) {
56 | if (day.from === 0 && day.to === 0) {
57 | updatedWorkingHours[dayId] = { from: 0, to: 24 };
58 | } else if (day.to === 0 && day.from > 0) {
59 | updatedWorkingHours[dayId] = { ...day, to: 24 };
60 | }
61 | } else {
62 | updatedWorkingHours[dayId] = { from: 0, to: 0 };
63 | }
64 | }
65 |
66 | setWorkingHours(updatedWorkingHours);
67 | };
68 |
69 | return (
70 |
71 |
72 |
Change working hours
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | This will apply a dashed background to the hour cells that fall outside the working hours — only for week and day views.
82 |
83 |
84 |
85 |
86 |
87 |
88 | {DAYS_OF_WEEK.map(day => {
89 | const isDayActive = localWorkingHours[day.index].from > 0 || localWorkingHours[day.index].to > 0;
90 |
91 | return (
92 |
93 |
94 | handleToggleDay(day.index)} />
95 | {day.name}
96 |
97 |
98 | {isDayActive ? (
99 |
100 |
101 | From
102 | handleTimeChange(day.index, "from", value)}
108 | />
109 |
110 |
111 |
112 | To
113 | handleTimeChange(day.index, "to", value)}
119 | />
120 |
121 |
122 | ) : (
123 |
124 |
125 | Closed
126 |
127 | )}
128 |
129 | );
130 | })}
131 |
132 |
133 |
136 |
137 | );
138 | }
139 |
--------------------------------------------------------------------------------
/src/calendar/components/client-container.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useMemo } from "react";
4 | import { isSameDay, parseISO } from "date-fns";
5 |
6 | import { useCalendar } from "@/calendar/contexts/calendar-context";
7 |
8 | import { DndProviderWrapper } from "@/calendar/components/dnd/dnd-provider";
9 |
10 | import { CalendarHeader } from "@/calendar/components/header/calendar-header";
11 | import { CalendarYearView } from "@/calendar/components/year-view/calendar-year-view";
12 | import { CalendarMonthView } from "@/calendar/components/month-view/calendar-month-view";
13 | import { CalendarAgendaView } from "@/calendar/components/agenda-view/calendar-agenda-view";
14 | import { CalendarDayView } from "@/calendar/components/week-and-day-view/calendar-day-view";
15 | import { CalendarWeekView } from "@/calendar/components/week-and-day-view/calendar-week-view";
16 |
17 | import type { TCalendarView } from "@/calendar/types";
18 |
19 | interface IProps {
20 | view: TCalendarView;
21 | }
22 |
23 | export function ClientContainer({ view }: IProps) {
24 | const { selectedDate, selectedUserId, events } = useCalendar();
25 |
26 | const filteredEvents = useMemo(() => {
27 | return events.filter(event => {
28 | const eventStartDate = parseISO(event.startDate);
29 | const eventEndDate = parseISO(event.endDate);
30 |
31 | if (view === "year") {
32 | const yearStart = new Date(selectedDate.getFullYear(), 0, 1);
33 | const yearEnd = new Date(selectedDate.getFullYear(), 11, 31, 23, 59, 59, 999);
34 | const isInSelectedYear = eventStartDate <= yearEnd && eventEndDate >= yearStart;
35 | const isUserMatch = selectedUserId === "all" || event.user.id === selectedUserId;
36 | return isInSelectedYear && isUserMatch;
37 | }
38 |
39 | if (view === "month" || view === "agenda") {
40 | const monthStart = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
41 | const monthEnd = new Date(selectedDate.getFullYear(), selectedDate.getMonth() + 1, 0, 23, 59, 59, 999);
42 | const isInSelectedMonth = eventStartDate <= monthEnd && eventEndDate >= monthStart;
43 | const isUserMatch = selectedUserId === "all" || event.user.id === selectedUserId;
44 | return isInSelectedMonth && isUserMatch;
45 | }
46 |
47 | if (view === "week") {
48 | const dayOfWeek = selectedDate.getDay();
49 |
50 | const weekStart = new Date(selectedDate);
51 | weekStart.setDate(selectedDate.getDate() - dayOfWeek);
52 | weekStart.setHours(0, 0, 0, 0);
53 |
54 | const weekEnd = new Date(weekStart);
55 | weekEnd.setDate(weekStart.getDate() + 6);
56 | weekEnd.setHours(23, 59, 59, 999);
57 |
58 | const isInSelectedWeek = eventStartDate <= weekEnd && eventEndDate >= weekStart;
59 | const isUserMatch = selectedUserId === "all" || event.user.id === selectedUserId;
60 | return isInSelectedWeek && isUserMatch;
61 | }
62 |
63 | if (view === "day") {
64 | const dayStart = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate(), 0, 0, 0);
65 | const dayEnd = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate(), 23, 59, 59);
66 | const isInSelectedDay = eventStartDate <= dayEnd && eventEndDate >= dayStart;
67 | const isUserMatch = selectedUserId === "all" || event.user.id === selectedUserId;
68 | return isInSelectedDay && isUserMatch;
69 | }
70 | });
71 | }, [selectedDate, selectedUserId, events, view]);
72 |
73 | const singleDayEvents = filteredEvents.filter(event => {
74 | const startDate = parseISO(event.startDate);
75 | const endDate = parseISO(event.endDate);
76 | return isSameDay(startDate, endDate);
77 | });
78 |
79 | const multiDayEvents = filteredEvents.filter(event => {
80 | const startDate = parseISO(event.startDate);
81 | const endDate = parseISO(event.endDate);
82 | return !isSameDay(startDate, endDate);
83 | });
84 |
85 | // For year view, we only care about the start date
86 | // by using the same date for both start and end,
87 | // we ensure only the start day will show a dot
88 | const eventStartDates = useMemo(() => {
89 | return filteredEvents.map(event => ({ ...event, endDate: event.startDate }));
90 | }, [filteredEvents]);
91 |
92 | return (
93 |
94 |
95 |
96 |
97 | {view === "day" && }
98 | {view === "month" && }
99 | {view === "week" && }
100 | {view === "year" && }
101 | {view === "agenda" && }
102 |
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/src/calendar/components/dialogs/add-event-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useForm } from "react-hook-form";
4 | import { zodResolver } from "@hookform/resolvers/zod";
5 |
6 | import { useDisclosure } from "@/hooks/use-disclosure";
7 | import { useCalendar } from "@/calendar/contexts/calendar-context";
8 |
9 | import { Input } from "@/components/ui/input";
10 | import { Button } from "@/components/ui/button";
11 | import { Textarea } from "@/components/ui/textarea";
12 | import { TimeInput } from "@/components/ui/time-input";
13 | import { SingleDayPicker } from "@/components/ui/single-day-picker";
14 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
15 | import { Form, FormField, FormLabel, FormItem, FormControl, FormMessage } from "@/components/ui/form";
16 | import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from "@/components/ui/select";
17 | import { Dialog, DialogHeader, DialogClose, DialogContent, DialogTrigger, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
18 |
19 | import { eventSchema } from "@/calendar/schemas";
20 |
21 | import type { TimeValue } from "react-aria-components";
22 | import type { TEventFormData } from "@/calendar/schemas";
23 |
24 | interface IProps {
25 | children: React.ReactNode;
26 | startDate?: Date;
27 | startTime?: { hour: number; minute: number };
28 | }
29 |
30 | export function AddEventDialog({ children, startDate, startTime }: IProps) {
31 | const { users } = useCalendar();
32 |
33 | const { isOpen, onClose, onToggle } = useDisclosure();
34 |
35 | const form = useForm({
36 | resolver: zodResolver(eventSchema),
37 | defaultValues: {
38 | title: "",
39 | description: "",
40 | startDate: typeof startDate !== "undefined" ? startDate : undefined,
41 | startTime: typeof startTime !== "undefined" ? startTime : undefined,
42 | },
43 | });
44 |
45 | const onSubmit = (_values: TEventFormData) => {
46 | // TO DO: Create use-add-event hook
47 | onClose();
48 | form.reset();
49 | };
50 |
51 | return (
52 |
291 | );
292 | }
293 |
--------------------------------------------------------------------------------
/src/calendar/components/dialogs/edit-event-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { parseISO } from "date-fns";
4 | import { useForm } from "react-hook-form";
5 | import { zodResolver } from "@hookform/resolvers/zod";
6 |
7 | import { useDisclosure } from "@/hooks/use-disclosure";
8 | import { useCalendar } from "@/calendar/contexts/calendar-context";
9 | import { useUpdateEvent } from "@/calendar/hooks/use-update-event";
10 |
11 | import { Input } from "@/components/ui/input";
12 | import { Button } from "@/components/ui/button";
13 | import { Textarea } from "@/components/ui/textarea";
14 | import { TimeInput } from "@/components/ui/time-input";
15 | import { SingleDayPicker } from "@/components/ui/single-day-picker";
16 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
17 | import { Form, FormField, FormLabel, FormItem, FormControl, FormMessage } from "@/components/ui/form";
18 | import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from "@/components/ui/select";
19 | import { Dialog, DialogHeader, DialogClose, DialogContent, DialogTrigger, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
20 |
21 | import { eventSchema } from "@/calendar/schemas";
22 |
23 | import type { IEvent } from "@/calendar/interfaces";
24 | import type { TimeValue } from "react-aria-components";
25 | import type { TEventFormData } from "@/calendar/schemas";
26 |
27 | interface IProps {
28 | children: React.ReactNode;
29 | event: IEvent;
30 | }
31 |
32 | export function EditEventDialog({ children, event }: IProps) {
33 | const { isOpen, onClose, onToggle } = useDisclosure();
34 |
35 | const { users } = useCalendar();
36 |
37 | const { updateEvent } = useUpdateEvent();
38 |
39 | const form = useForm({
40 | resolver: zodResolver(eventSchema),
41 | defaultValues: {
42 | user: event.user.id,
43 | title: event.title,
44 | description: event.description,
45 | startDate: parseISO(event.startDate),
46 | startTime: { hour: parseISO(event.startDate).getHours(), minute: parseISO(event.startDate).getMinutes() },
47 | endDate: parseISO(event.endDate),
48 | endTime: { hour: parseISO(event.endDate).getHours(), minute: parseISO(event.endDate).getMinutes() },
49 | color: event.color,
50 | },
51 | });
52 |
53 | const onSubmit = (values: TEventFormData) => {
54 | const user = users.find(user => user.id === values.user);
55 |
56 | if (!user) throw new Error("User not found");
57 |
58 | const startDateTime = new Date(values.startDate);
59 | startDateTime.setHours(values.startTime.hour, values.startTime.minute);
60 |
61 | const endDateTime = new Date(values.endDate);
62 | endDateTime.setHours(values.endTime.hour, values.endTime.minute);
63 |
64 | updateEvent({
65 | ...event,
66 | user,
67 | title: values.title,
68 | color: values.color,
69 | description: values.description,
70 | startDate: startDateTime.toISOString(),
71 | endDate: endDateTime.toISOString(),
72 | });
73 |
74 | onClose();
75 | };
76 |
77 | return (
78 |
315 | );
316 | }
317 |
--------------------------------------------------------------------------------
/src/calendar/components/dialogs/event-details-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { format, parseISO } from "date-fns";
4 | import { Calendar, Clock, Text, User } from "lucide-react";
5 |
6 | import { Button } from "@/components/ui/button";
7 | import { EditEventDialog } from "@/calendar/components/dialogs/edit-event-dialog";
8 | import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
9 |
10 | import type { IEvent } from "@/calendar/interfaces";
11 |
12 | interface IProps {
13 | event: IEvent;
14 | children: React.ReactNode;
15 | }
16 |
17 | export function EventDetailsDialog({ event, children }: IProps) {
18 | const startDate = parseISO(event.startDate);
19 | const endDate = parseISO(event.endDate);
20 |
21 | return (
22 | <>
23 |
74 | >
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/src/calendar/components/dnd/dnd-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { DndProvider } from "react-dnd";
4 | import { HTML5Backend } from "react-dnd-html5-backend";
5 |
6 | interface DndProviderWrapperProps {
7 | children: React.ReactNode;
8 | }
9 |
10 | export function DndProviderWrapper({ children }: DndProviderWrapperProps) {
11 | return {children};
12 | }
13 |
--------------------------------------------------------------------------------
/src/calendar/components/dnd/draggable-event.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRef } from "react";
4 | import { useDrag } from "react-dnd";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | import type { IEvent } from "@/calendar/interfaces";
9 |
10 | export const ItemTypes = {
11 | EVENT: "event",
12 | };
13 |
14 | interface DraggableEventProps {
15 | event: IEvent;
16 | children: React.ReactNode;
17 | }
18 |
19 | export function DraggableEvent({ event, children }: DraggableEventProps) {
20 | const ref = useRef(null);
21 |
22 | const [{ isDragging }, drag] = useDrag(() => ({
23 | type: ItemTypes.EVENT,
24 | item: { event },
25 | collect: monitor => ({ isDragging: monitor.isDragging() }),
26 | }));
27 |
28 | drag(ref);
29 |
30 | return (
31 |
32 | {children}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/calendar/components/dnd/droppable-day-cell.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useDrop } from "react-dnd";
4 | import { parseISO, differenceInMilliseconds } from "date-fns";
5 |
6 | import { useUpdateEvent } from "@/calendar/hooks/use-update-event";
7 |
8 | import { cn } from "@/lib/utils";
9 | import { ItemTypes } from "@/calendar/components/dnd/draggable-event";
10 |
11 | import type { IEvent, ICalendarCell } from "@/calendar/interfaces";
12 |
13 | interface DroppableDayCellProps {
14 | cell: ICalendarCell;
15 | children: React.ReactNode;
16 | }
17 |
18 | export function DroppableDayCell({ cell, children }: DroppableDayCellProps) {
19 | const { updateEvent } = useUpdateEvent();
20 |
21 | const [{ isOver, canDrop }, drop] = useDrop(
22 | () => ({
23 | accept: ItemTypes.EVENT,
24 | drop: (item: { event: IEvent }) => {
25 | const droppedEvent = item.event;
26 |
27 | const eventStartDate = parseISO(droppedEvent.startDate);
28 | const eventEndDate = parseISO(droppedEvent.endDate);
29 |
30 | const eventDurationMs = differenceInMilliseconds(eventEndDate, eventStartDate);
31 |
32 | const newStartDate = new Date(cell.date);
33 | newStartDate.setHours(eventStartDate.getHours(), eventStartDate.getMinutes(), eventStartDate.getSeconds(), eventStartDate.getMilliseconds());
34 | const newEndDate = new Date(newStartDate.getTime() + eventDurationMs);
35 |
36 | updateEvent({
37 | ...droppedEvent,
38 | startDate: newStartDate.toISOString(),
39 | endDate: newEndDate.toISOString(),
40 | });
41 |
42 | return { moved: true };
43 | },
44 | collect: monitor => ({
45 | isOver: monitor.isOver(),
46 | canDrop: monitor.canDrop(),
47 | }),
48 | }),
49 | [cell.date, updateEvent]
50 | );
51 |
52 | return (
53 | } className={cn(isOver && canDrop && "bg-accent/50")}>
54 | {children}
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/src/calendar/components/dnd/droppable-time-block.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useDrop } from "react-dnd";
4 | import { parseISO, differenceInMilliseconds } from "date-fns";
5 |
6 | import { useUpdateEvent } from "@/calendar/hooks/use-update-event";
7 |
8 | import { cn } from "@/lib/utils";
9 | import { ItemTypes } from "@/calendar/components/dnd/draggable-event";
10 |
11 | import type { IEvent } from "@/calendar/interfaces";
12 |
13 | interface DroppableTimeBlockProps {
14 | date: Date;
15 | hour: number;
16 | minute: number;
17 | children: React.ReactNode;
18 | }
19 |
20 | export function DroppableTimeBlock({ date, hour, minute, children }: DroppableTimeBlockProps) {
21 | const { updateEvent } = useUpdateEvent();
22 |
23 | const [{ isOver, canDrop }, drop] = useDrop(
24 | () => ({
25 | accept: ItemTypes.EVENT,
26 | drop: (item: { event: IEvent }) => {
27 | const droppedEvent = item.event;
28 |
29 | const eventStartDate = parseISO(droppedEvent.startDate);
30 | const eventEndDate = parseISO(droppedEvent.endDate);
31 |
32 | const eventDurationMs = differenceInMilliseconds(eventEndDate, eventStartDate);
33 |
34 | const newStartDate = new Date(date);
35 | newStartDate.setHours(hour, minute, 0, 0);
36 | const newEndDate = new Date(newStartDate.getTime() + eventDurationMs);
37 |
38 | updateEvent({
39 | ...droppedEvent,
40 | startDate: newStartDate.toISOString(),
41 | endDate: newEndDate.toISOString(),
42 | });
43 |
44 | return { moved: true };
45 | },
46 | collect: monitor => ({
47 | isOver: monitor.isOver(),
48 | canDrop: monitor.canDrop(),
49 | }),
50 | }),
51 | [date, hour, minute, updateEvent]
52 | );
53 |
54 | return (
55 | } className={cn("h-[24px]", isOver && canDrop && "bg-accent/50")}>
56 | {children}
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/calendar/components/header/calendar-header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Columns, Grid3x3, List, Plus, Grid2x2, CalendarRange } from "lucide-react";
3 |
4 | import { Button } from "@/components/ui/button";
5 |
6 | import { UserSelect } from "@/calendar/components/header/user-select";
7 | import { TodayButton } from "@/calendar/components/header/today-button";
8 | import { DateNavigator } from "@/calendar/components/header/date-navigator";
9 | import { AddEventDialog } from "@/calendar/components/dialogs/add-event-dialog";
10 |
11 | import type { IEvent } from "@/calendar/interfaces";
12 | import type { TCalendarView } from "@/calendar/types";
13 |
14 | interface IProps {
15 | view: TCalendarView;
16 | events: IEvent[];
17 | }
18 |
19 | export function CalendarHeader({ view, events }: IProps) {
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
35 |
36 |
47 |
48 |
59 |
60 |
71 |
72 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
93 |
94 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/src/calendar/components/header/date-navigator.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { formatDate } from "date-fns";
3 | import { ChevronLeft, ChevronRight } from "lucide-react";
4 |
5 | import { useCalendar } from "@/calendar/contexts/calendar-context";
6 |
7 | import { Badge } from "@/components/ui/badge";
8 | import { Button } from "@/components/ui/button";
9 |
10 | import { getEventsCount, navigateDate, rangeText } from "@/calendar/helpers";
11 |
12 | import type { IEvent } from "@/calendar/interfaces";
13 | import type { TCalendarView } from "@/calendar/types";
14 |
15 | interface IProps {
16 | view: TCalendarView;
17 | events: IEvent[];
18 | }
19 |
20 | export function DateNavigator({ view, events }: IProps) {
21 | const { selectedDate, setSelectedDate } = useCalendar();
22 |
23 | const month = formatDate(selectedDate, "MMMM");
24 | const year = selectedDate.getFullYear();
25 |
26 | const eventCount = useMemo(() => getEventsCount(events, selectedDate, view), [events, selectedDate, view]);
27 |
28 | const handlePrevious = () => setSelectedDate(navigateDate(selectedDate, view, "previous"));
29 | const handleNext = () => setSelectedDate(navigateDate(selectedDate, view, "next"));
30 |
31 | return (
32 |
33 |
34 |
35 | {month} {year}
36 |
37 |
38 | {eventCount} events
39 |
40 |
41 |
42 |
43 |
46 |
47 |
{rangeText(view, selectedDate)}
48 |
49 |
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/calendar/components/header/today-button.tsx:
--------------------------------------------------------------------------------
1 | import { formatDate } from "date-fns";
2 |
3 | import { useCalendar } from "@/calendar/contexts/calendar-context";
4 |
5 | export function TodayButton() {
6 | const { setSelectedDate } = useCalendar();
7 |
8 | const today = new Date();
9 | const handleClick = () => setSelectedDate(today);
10 |
11 | return (
12 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/calendar/components/header/user-select.tsx:
--------------------------------------------------------------------------------
1 | import { useCalendar } from "@/calendar/contexts/calendar-context";
2 |
3 | import { AvatarGroup } from "@/components/ui/avatar-group";
4 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
5 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
6 |
7 | export function UserSelect() {
8 | const { users, selectedUserId, setSelectedUserId } = useCalendar();
9 |
10 | return (
11 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/calendar/components/month-view/calendar-month-view.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 |
3 | import { useCalendar } from "@/calendar/contexts/calendar-context";
4 |
5 | import { DayCell } from "@/calendar/components/month-view/day-cell";
6 |
7 | import { getCalendarCells, calculateMonthEventPositions } from "@/calendar/helpers";
8 |
9 | import type { IEvent } from "@/calendar/interfaces";
10 |
11 | interface IProps {
12 | singleDayEvents: IEvent[];
13 | multiDayEvents: IEvent[];
14 | }
15 |
16 | const WEEK_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
17 |
18 | export function CalendarMonthView({ singleDayEvents, multiDayEvents }: IProps) {
19 | const { selectedDate } = useCalendar();
20 |
21 | const allEvents = [...multiDayEvents, ...singleDayEvents];
22 |
23 | const cells = useMemo(() => getCalendarCells(selectedDate), [selectedDate]);
24 |
25 | const eventPositions = useMemo(
26 | () => calculateMonthEventPositions(multiDayEvents, singleDayEvents, selectedDate),
27 | [multiDayEvents, singleDayEvents, selectedDate]
28 | );
29 |
30 | return (
31 |
32 |
33 | {WEEK_DAYS.map(day => (
34 |
35 | {day}
36 |
37 | ))}
38 |
39 |
40 |
41 | {cells.map(cell => (
42 |
43 | ))}
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/calendar/components/month-view/day-cell.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { isToday, startOfDay } from "date-fns";
3 |
4 | import { EventBullet } from "@/calendar/components/month-view/event-bullet";
5 | import { DroppableDayCell } from "@/calendar/components/dnd/droppable-day-cell";
6 | import { MonthEventBadge } from "@/calendar/components/month-view/month-event-badge";
7 |
8 | import { cn } from "@/lib/utils";
9 | import { getMonthCellEvents } from "@/calendar/helpers";
10 |
11 | import type { ICalendarCell, IEvent } from "@/calendar/interfaces";
12 |
13 | interface IProps {
14 | cell: ICalendarCell;
15 | events: IEvent[];
16 | eventPositions: Record;
17 | }
18 |
19 | const MAX_VISIBLE_EVENTS = 3;
20 |
21 | export function DayCell({ cell, events, eventPositions }: IProps) {
22 | const { day, currentMonth, date } = cell;
23 |
24 | const cellEvents = useMemo(() => getMonthCellEvents(date, events, eventPositions), [date, events, eventPositions]);
25 | const isSunday = date.getDay() === 0;
26 |
27 | return (
28 |
29 |
30 |
37 | {day}
38 |
39 |
40 |
41 | {[0, 1, 2].map(position => {
42 | const event = cellEvents.find(e => e.position === position);
43 | const eventKey = event ? `event-${event.id}-${position}` : `empty-${position}`;
44 |
45 | return (
46 |
47 | {event && (
48 | <>
49 |
50 |
51 | >
52 | )}
53 |
54 | );
55 | })}
56 |
57 |
58 | {cellEvents.length > MAX_VISIBLE_EVENTS && (
59 |
60 | +{cellEvents.length - MAX_VISIBLE_EVENTS}
61 | {cellEvents.length - MAX_VISIBLE_EVENTS} more...
62 |
63 | )}
64 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/src/calendar/components/month-view/event-bullet.tsx:
--------------------------------------------------------------------------------
1 | import { cva } from "class-variance-authority";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | import type { TEventColor } from "@/calendar/types";
6 |
7 | const eventBulletVariants = cva("size-2 rounded-full", {
8 | variants: {
9 | color: {
10 | blue: "bg-blue-600 dark:bg-blue-500",
11 | green: "bg-green-600 dark:bg-green-500",
12 | red: "bg-red-600 dark:bg-red-500",
13 | yellow: "bg-yellow-600 dark:bg-yellow-500",
14 | purple: "bg-purple-600 dark:bg-purple-500",
15 | gray: "bg-neutral-600 dark:bg-neutral-500",
16 | orange: "bg-orange-600 dark:bg-orange-500",
17 | },
18 | },
19 | defaultVariants: {
20 | color: "blue",
21 | },
22 | });
23 |
24 | export function EventBullet({ color, className }: { color: TEventColor; className: string }) {
25 | return ;
26 | }
27 |
--------------------------------------------------------------------------------
/src/calendar/components/month-view/month-event-badge.tsx:
--------------------------------------------------------------------------------
1 | import { cva } from "class-variance-authority";
2 | import { endOfDay, format, isSameDay, parseISO, startOfDay } from "date-fns";
3 |
4 | import { useCalendar } from "@/calendar/contexts/calendar-context";
5 |
6 | import { DraggableEvent } from "@/calendar/components/dnd/draggable-event";
7 | import { EventDetailsDialog } from "@/calendar/components/dialogs/event-details-dialog";
8 |
9 | import { cn } from "@/lib/utils";
10 |
11 | import type { IEvent } from "@/calendar/interfaces";
12 | import type { VariantProps } from "class-variance-authority";
13 |
14 | const eventBadgeVariants = cva(
15 | "mx-1 flex size-auto h-6.5 select-none items-center justify-between gap-1.5 truncate whitespace-nowrap rounded-md border px-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
16 | {
17 | variants: {
18 | color: {
19 | // Colored and mixed variants
20 | blue: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300 [&_.event-dot]:fill-blue-600",
21 | green: "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300 [&_.event-dot]:fill-green-600",
22 | red: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300 [&_.event-dot]:fill-red-600",
23 | yellow: "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300 [&_.event-dot]:fill-yellow-600",
24 | purple: "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300 [&_.event-dot]:fill-purple-600",
25 | orange: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300 [&_.event-dot]:fill-orange-600",
26 | gray: "border-neutral-200 bg-neutral-50 text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 [&_.event-dot]:fill-neutral-600",
27 |
28 | // Dot variants
29 | "blue-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-blue-600",
30 | "green-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-green-600",
31 | "red-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-red-600",
32 | "yellow-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-yellow-600",
33 | "purple-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-purple-600",
34 | "orange-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-orange-600",
35 | "gray-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-neutral-600",
36 | },
37 | multiDayPosition: {
38 | first: "relative z-10 mr-0 w-[calc(100%_-_3px)] rounded-r-none border-r-0 [&>span]:mr-2.5",
39 | middle: "relative z-10 mx-0 w-[calc(100%_+_1px)] rounded-none border-x-0",
40 | last: "ml-0 rounded-l-none border-l-0",
41 | none: "",
42 | },
43 | },
44 | defaultVariants: {
45 | color: "blue-dot",
46 | },
47 | }
48 | );
49 |
50 | interface IProps extends Omit, "color" | "multiDayPosition"> {
51 | event: IEvent;
52 | cellDate: Date;
53 | eventCurrentDay?: number;
54 | eventTotalDays?: number;
55 | className?: string;
56 | position?: "first" | "middle" | "last" | "none";
57 | }
58 |
59 | export function MonthEventBadge({ event, cellDate, eventCurrentDay, eventTotalDays, className, position: propPosition }: IProps) {
60 | const { badgeVariant } = useCalendar();
61 |
62 | const itemStart = startOfDay(parseISO(event.startDate));
63 | const itemEnd = endOfDay(parseISO(event.endDate));
64 |
65 | if (cellDate < itemStart || cellDate > itemEnd) return null;
66 |
67 | let position: "first" | "middle" | "last" | "none" | undefined;
68 |
69 | if (propPosition) {
70 | position = propPosition;
71 | } else if (eventCurrentDay && eventTotalDays) {
72 | position = "none";
73 | } else if (isSameDay(itemStart, itemEnd)) {
74 | position = "none";
75 | } else if (isSameDay(cellDate, itemStart)) {
76 | position = "first";
77 | } else if (isSameDay(cellDate, itemEnd)) {
78 | position = "last";
79 | } else {
80 | position = "middle";
81 | }
82 |
83 | const renderBadgeText = ["first", "none"].includes(position);
84 |
85 | const color = (badgeVariant === "dot" ? `${event.color}-dot` : event.color) as VariantProps["color"];
86 |
87 | const eventBadgeClasses = cn(eventBadgeVariants({ color, multiDayPosition: position, className }));
88 |
89 | const handleKeyDown = (e: React.KeyboardEvent) => {
90 | if (e.key === "Enter" || e.key === " ") {
91 | e.preventDefault();
92 | if (e.currentTarget instanceof HTMLElement) e.currentTarget.click();
93 | }
94 | };
95 |
96 | return (
97 |
98 |
99 |
100 |
101 | {!["middle", "last"].includes(position) && ["mixed", "dot"].includes(badgeVariant) && (
102 |
105 | )}
106 |
107 | {renderBadgeText && (
108 |
109 | {eventCurrentDay && (
110 |
111 | Day {eventCurrentDay} of {eventTotalDays} •{" "}
112 |
113 | )}
114 | {event.title}
115 |
116 | )}
117 |
118 |
119 | {renderBadgeText &&
{format(new Date(event.startDate), "h:mm a")}}
120 |
121 |
122 |
123 | );
124 | }
125 |
--------------------------------------------------------------------------------
/src/calendar/components/week-and-day-view/calendar-day-view.tsx:
--------------------------------------------------------------------------------
1 | import { Calendar, Clock, User } from "lucide-react";
2 | import { parseISO, areIntervalsOverlapping, format } from "date-fns";
3 |
4 | import { useCalendar } from "@/calendar/contexts/calendar-context";
5 |
6 | import { ScrollArea } from "@/components/ui/scroll-area";
7 | import { SingleCalendar } from "@/components/ui/single-calendar";
8 |
9 | import { AddEventDialog } from "@/calendar/components/dialogs/add-event-dialog";
10 | import { EventBlock } from "@/calendar/components/week-and-day-view/event-block";
11 | import { DroppableTimeBlock } from "@/calendar/components/dnd/droppable-time-block";
12 | import { CalendarTimeline } from "@/calendar/components/week-and-day-view/calendar-time-line";
13 | import { DayViewMultiDayEventsRow } from "@/calendar/components/week-and-day-view/day-view-multi-day-events-row";
14 |
15 | import { cn } from "@/lib/utils";
16 | import { groupEvents, getEventBlockStyle, isWorkingHour, getCurrentEvents, getVisibleHours } from "@/calendar/helpers";
17 |
18 | import type { IEvent } from "@/calendar/interfaces";
19 |
20 | interface IProps {
21 | singleDayEvents: IEvent[];
22 | multiDayEvents: IEvent[];
23 | }
24 |
25 | export function CalendarDayView({ singleDayEvents, multiDayEvents }: IProps) {
26 | const { selectedDate, setSelectedDate, users, visibleHours, workingHours } = useCalendar();
27 |
28 | const { hours, earliestEventHour, latestEventHour } = getVisibleHours(visibleHours, singleDayEvents);
29 |
30 | const currentEvents = getCurrentEvents(singleDayEvents);
31 |
32 | const dayEvents = singleDayEvents.filter(event => {
33 | const eventDate = parseISO(event.startDate);
34 | return (
35 | eventDate.getDate() === selectedDate.getDate() &&
36 | eventDate.getMonth() === selectedDate.getMonth() &&
37 | eventDate.getFullYear() === selectedDate.getFullYear()
38 | );
39 | });
40 |
41 | const groupedEvents = groupEvents(dayEvents);
42 |
43 | return (
44 |
45 |
46 |
47 |
48 |
49 | {/* Day header */}
50 |
51 |
52 |
53 | {format(selectedDate, "EE")} {format(selectedDate, "d")}
54 |
55 |
56 |
57 |
58 |
59 |
60 | {/* Hours column */}
61 |
62 | {hours.map((hour, index) => (
63 |
64 |
65 | {index !== 0 && {format(new Date().setHours(hour, 0, 0, 0), "hh a")}}
66 |
67 |
68 | ))}
69 |
70 |
71 | {/* Day grid */}
72 |
73 |
74 | {hours.map((hour, index) => {
75 | const isDisabled = !isWorkingHour(selectedDate, hour, workingHours);
76 |
77 | return (
78 |
79 | {index !== 0 &&
}
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | );
108 | })}
109 |
110 | {groupedEvents.map((group, groupIndex) =>
111 | group.map(event => {
112 | let style = getEventBlockStyle(event, selectedDate, groupIndex, groupedEvents.length, { from: earliestEventHour, to: latestEventHour });
113 | const hasOverlap = groupedEvents.some(
114 | (otherGroup, otherIndex) =>
115 | otherIndex !== groupIndex &&
116 | otherGroup.some(otherEvent =>
117 | areIntervalsOverlapping(
118 | { start: parseISO(event.startDate), end: parseISO(event.endDate) },
119 | { start: parseISO(otherEvent.startDate), end: parseISO(otherEvent.endDate) }
120 | )
121 | )
122 | );
123 |
124 | if (!hasOverlap) style = { ...style, width: "100%", left: "0%" };
125 |
126 | return (
127 |
128 |
129 |
130 | );
131 | })
132 | )}
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | {currentEvents.length > 0 ? (
146 |
147 |
148 |
149 |
150 |
151 |
152 |
Happening now
153 |
154 | ) : (
155 |
No appointments or consultations at the moment
156 | )}
157 |
158 | {currentEvents.length > 0 && (
159 |
160 |
161 | {currentEvents.map(event => {
162 | const user = users.find(user => user.id === event.user.id);
163 |
164 | return (
165 |
166 |
{event.title}
167 |
168 | {user && (
169 |
170 |
171 | {user.name}
172 |
173 | )}
174 |
175 |
176 |
177 | {format(new Date(), "MMM d, yyyy")}
178 |
179 |
180 |
181 |
182 |
183 | {format(parseISO(event.startDate), "h:mm a")} - {format(parseISO(event.endDate), "h:mm a")}
184 |
185 |
186 |
187 | );
188 | })}
189 |
190 |
191 | )}
192 |
193 |
194 |
195 | );
196 | }
197 |
--------------------------------------------------------------------------------
/src/calendar/components/week-and-day-view/calendar-time-line.tsx:
--------------------------------------------------------------------------------
1 | import { format } from "date-fns";
2 | import { useEffect, useState } from "react";
3 |
4 | interface IProps {
5 | firstVisibleHour: number;
6 | lastVisibleHour: number;
7 | }
8 |
9 | export function CalendarTimeline({ firstVisibleHour, lastVisibleHour }: IProps) {
10 | const [currentTime, setCurrentTime] = useState(new Date());
11 |
12 | useEffect(() => {
13 | const timer = setInterval(() => setCurrentTime(new Date()), 60 * 1000);
14 | return () => clearInterval(timer);
15 | }, []);
16 |
17 | const getCurrentTimePosition = () => {
18 | const minutes = currentTime.getHours() * 60 + currentTime.getMinutes();
19 |
20 | const visibleStartMinutes = firstVisibleHour * 60;
21 | const visibleEndMinutes = lastVisibleHour * 60;
22 | const visibleRangeMinutes = visibleEndMinutes - visibleStartMinutes;
23 |
24 | return ((minutes - visibleStartMinutes) / visibleRangeMinutes) * 100;
25 | };
26 |
27 | const formatCurrentTime = () => {
28 | return format(currentTime, "h:mm a");
29 | };
30 |
31 | const currentHour = currentTime.getHours();
32 | if (currentHour < firstVisibleHour || currentHour >= lastVisibleHour) return null;
33 |
34 | return (
35 |
36 |
37 |
{formatCurrentTime()}
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/calendar/components/week-and-day-view/calendar-week-view.tsx:
--------------------------------------------------------------------------------
1 | import { startOfWeek, addDays, format, parseISO, isSameDay, areIntervalsOverlapping } from "date-fns";
2 |
3 | import { useCalendar } from "@/calendar/contexts/calendar-context";
4 |
5 | import { ScrollArea } from "@/components/ui/scroll-area";
6 |
7 | import { AddEventDialog } from "@/calendar/components/dialogs/add-event-dialog";
8 | import { EventBlock } from "@/calendar/components/week-and-day-view/event-block";
9 | import { DroppableTimeBlock } from "@/calendar/components/dnd/droppable-time-block";
10 | import { CalendarTimeline } from "@/calendar/components/week-and-day-view/calendar-time-line";
11 | import { WeekViewMultiDayEventsRow } from "@/calendar/components/week-and-day-view/week-view-multi-day-events-row";
12 |
13 | import { cn } from "@/lib/utils";
14 | import { groupEvents, getEventBlockStyle, isWorkingHour, getVisibleHours } from "@/calendar/helpers";
15 |
16 | import type { IEvent } from "@/calendar/interfaces";
17 |
18 | interface IProps {
19 | singleDayEvents: IEvent[];
20 | multiDayEvents: IEvent[];
21 | }
22 |
23 | export function CalendarWeekView({ singleDayEvents, multiDayEvents }: IProps) {
24 | const { selectedDate, workingHours, visibleHours } = useCalendar();
25 |
26 | const { hours, earliestEventHour, latestEventHour } = getVisibleHours(visibleHours, singleDayEvents);
27 |
28 | const weekStart = startOfWeek(selectedDate);
29 | const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
30 |
31 | return (
32 | <>
33 |
34 |
Weekly view is not available on smaller devices.
35 |
Please switch to daily or monthly view.
36 |
37 |
38 |
39 |
40 |
41 |
42 | {/* Week header */}
43 |
44 |
45 |
46 | {weekDays.map((day, index) => (
47 |
48 | {format(day, "EE")} {format(day, "d")}
49 |
50 | ))}
51 |
52 |
53 |
54 |
55 |
56 |
57 | {/* Hours column */}
58 |
59 | {hours.map((hour, index) => (
60 |
61 |
62 | {index !== 0 && {format(new Date().setHours(hour, 0, 0, 0), "hh a")}}
63 |
64 |
65 | ))}
66 |
67 |
68 | {/* Week grid */}
69 |
70 |
71 | {weekDays.map((day, dayIndex) => {
72 | const dayEvents = singleDayEvents.filter(event => isSameDay(parseISO(event.startDate), day) || isSameDay(parseISO(event.endDate), day));
73 | const groupedEvents = groupEvents(dayEvents);
74 |
75 | return (
76 |
77 | {hours.map((hour, index) => {
78 | const isDisabled = !isWorkingHour(day, hour, workingHours);
79 |
80 | return (
81 |
82 | {index !== 0 &&
}
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 | );
111 | })}
112 |
113 | {groupedEvents.map((group, groupIndex) =>
114 | group.map(event => {
115 | let style = getEventBlockStyle(event, day, groupIndex, groupedEvents.length, { from: earliestEventHour, to: latestEventHour });
116 | const hasOverlap = groupedEvents.some(
117 | (otherGroup, otherIndex) =>
118 | otherIndex !== groupIndex &&
119 | otherGroup.some(otherEvent =>
120 | areIntervalsOverlapping(
121 | { start: parseISO(event.startDate), end: parseISO(event.endDate) },
122 | { start: parseISO(otherEvent.startDate), end: parseISO(otherEvent.endDate) }
123 | )
124 | )
125 | );
126 |
127 | if (!hasOverlap) style = { ...style, width: "100%", left: "0%" };
128 |
129 | return (
130 |
131 |
132 |
133 | );
134 | })
135 | )}
136 |
137 | );
138 | })}
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 | >
147 | );
148 | }
149 |
--------------------------------------------------------------------------------
/src/calendar/components/week-and-day-view/day-view-multi-day-events-row.tsx:
--------------------------------------------------------------------------------
1 | import { parseISO, isWithinInterval, differenceInDays, startOfDay, endOfDay } from "date-fns";
2 |
3 | import { MonthEventBadge } from "@/calendar/components/month-view/month-event-badge";
4 |
5 | import type { IEvent } from "@/calendar/interfaces";
6 |
7 | interface IProps {
8 | selectedDate: Date;
9 | multiDayEvents: IEvent[];
10 | }
11 |
12 | export function DayViewMultiDayEventsRow({ selectedDate, multiDayEvents }: IProps) {
13 | const dayStart = startOfDay(selectedDate);
14 | const dayEnd = endOfDay(selectedDate);
15 |
16 | const multiDayEventsInDay = multiDayEvents
17 | .filter(event => {
18 | const eventStart = parseISO(event.startDate);
19 | const eventEnd = parseISO(event.endDate);
20 |
21 | const isOverlapping =
22 | isWithinInterval(dayStart, { start: eventStart, end: eventEnd }) ||
23 | isWithinInterval(dayEnd, { start: eventStart, end: eventEnd }) ||
24 | (eventStart <= dayStart && eventEnd >= dayEnd);
25 |
26 | return isOverlapping;
27 | })
28 | .sort((a, b) => {
29 | const durationA = differenceInDays(parseISO(a.endDate), parseISO(a.startDate));
30 | const durationB = differenceInDays(parseISO(b.endDate), parseISO(b.startDate));
31 | return durationB - durationA;
32 | });
33 |
34 | if (multiDayEventsInDay.length === 0) return null;
35 |
36 | return (
37 |
38 |
39 |
40 | {multiDayEventsInDay.map(event => {
41 | const eventStart = startOfDay(parseISO(event.startDate));
42 | const eventEnd = startOfDay(parseISO(event.endDate));
43 | const currentDate = startOfDay(selectedDate);
44 |
45 | const eventTotalDays = differenceInDays(eventEnd, eventStart) + 1;
46 | const eventCurrentDay = differenceInDays(currentDate, eventStart) + 1;
47 |
48 | return ;
49 | })}
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/calendar/components/week-and-day-view/event-block.tsx:
--------------------------------------------------------------------------------
1 | import { cva } from "class-variance-authority";
2 | import { format, differenceInMinutes, parseISO } from "date-fns";
3 |
4 | import { useCalendar } from "@/calendar/contexts/calendar-context";
5 |
6 | import { DraggableEvent } from "@/calendar/components/dnd/draggable-event";
7 | import { EventDetailsDialog } from "@/calendar/components/dialogs/event-details-dialog";
8 |
9 | import { cn } from "@/lib/utils";
10 |
11 | import type { HTMLAttributes } from "react";
12 | import type { IEvent } from "@/calendar/interfaces";
13 | import type { VariantProps } from "class-variance-authority";
14 |
15 | const calendarWeekEventCardVariants = cva(
16 | "flex select-none flex-col gap-0.5 truncate whitespace-nowrap rounded-md border px-2 py-1.5 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
17 | {
18 | variants: {
19 | color: {
20 | // Colored and mixed variants
21 | blue: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300 [&_.event-dot]:fill-blue-600",
22 | green: "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300 [&_.event-dot]:fill-green-600",
23 | red: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300 [&_.event-dot]:fill-red-600",
24 | yellow: "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300 [&_.event-dot]:fill-yellow-600",
25 | purple: "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300 [&_.event-dot]:fill-purple-600",
26 | orange: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300 [&_.event-dot]:fill-orange-600",
27 | gray: "border-neutral-200 bg-neutral-50 text-neutral-700 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 [&_.event-dot]:fill-neutral-600",
28 |
29 | // Dot variants
30 | "blue-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-blue-600",
31 | "green-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-green-600",
32 | "red-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-red-600",
33 | "orange-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-orange-600",
34 | "purple-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-purple-600",
35 | "yellow-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-yellow-600",
36 | "gray-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-neutral-600",
37 | },
38 | },
39 | defaultVariants: {
40 | color: "blue-dot",
41 | },
42 | }
43 | );
44 |
45 | interface IProps extends HTMLAttributes, Omit, "color"> {
46 | event: IEvent;
47 | }
48 |
49 | export function EventBlock({ event, className }: IProps) {
50 | const { badgeVariant } = useCalendar();
51 |
52 | const start = parseISO(event.startDate);
53 | const end = parseISO(event.endDate);
54 | const durationInMinutes = differenceInMinutes(end, start);
55 | const heightInPixels = (durationInMinutes / 60) * 96 - 8;
56 |
57 | const color = (badgeVariant === "dot" ? `${event.color}-dot` : event.color) as VariantProps["color"];
58 |
59 | const calendarWeekEventCardClasses = cn(calendarWeekEventCardVariants({ color, className }), durationInMinutes < 35 && "py-0 justify-center");
60 |
61 | const handleKeyDown = (e: React.KeyboardEvent) => {
62 | if (e.key === "Enter" || e.key === " ") {
63 | e.preventDefault();
64 | if (e.currentTarget instanceof HTMLElement) e.currentTarget.click();
65 | }
66 | };
67 |
68 | return (
69 |
70 |
71 |
72 |
73 | {["mixed", "dot"].includes(badgeVariant) && (
74 |
77 | )}
78 |
79 |
{event.title}
80 |
81 |
82 | {durationInMinutes > 25 && (
83 |
84 | {format(start, "h:mm a")} - {format(end, "h:mm a")}
85 |
86 | )}
87 |
88 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/src/calendar/components/week-and-day-view/week-view-multi-day-events-row.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { parseISO, startOfDay, startOfWeek, endOfWeek, addDays, differenceInDays, isBefore, isAfter } from "date-fns";
3 |
4 | import { MonthEventBadge } from "@/calendar/components/month-view/month-event-badge";
5 |
6 | import type { IEvent } from "@/calendar/interfaces";
7 |
8 | interface IProps {
9 | selectedDate: Date;
10 | multiDayEvents: IEvent[];
11 | }
12 |
13 | export function WeekViewMultiDayEventsRow({ selectedDate, multiDayEvents }: IProps) {
14 | const weekStart = startOfWeek(selectedDate);
15 | const weekEnd = endOfWeek(selectedDate);
16 | const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
17 |
18 | const processedEvents = useMemo(() => {
19 | return multiDayEvents
20 | .map(event => {
21 | const start = parseISO(event.startDate);
22 | const end = parseISO(event.endDate);
23 | const adjustedStart = isBefore(start, weekStart) ? weekStart : start;
24 | const adjustedEnd = isAfter(end, weekEnd) ? weekEnd : end;
25 | const startIndex = differenceInDays(adjustedStart, weekStart);
26 | const endIndex = differenceInDays(adjustedEnd, weekStart);
27 |
28 | return {
29 | ...event,
30 | adjustedStart,
31 | adjustedEnd,
32 | startIndex,
33 | endIndex,
34 | };
35 | })
36 | .sort((a, b) => {
37 | const startDiff = a.adjustedStart.getTime() - b.adjustedStart.getTime();
38 | if (startDiff !== 0) return startDiff;
39 | return b.endIndex - b.startIndex - (a.endIndex - a.startIndex);
40 | });
41 | }, [multiDayEvents, weekStart, weekEnd]);
42 |
43 | const eventRows = useMemo(() => {
44 | const rows: (typeof processedEvents)[] = [];
45 |
46 | processedEvents.forEach(event => {
47 | let rowIndex = rows.findIndex(row => row.every(e => e.endIndex < event.startIndex || e.startIndex > event.endIndex));
48 |
49 | if (rowIndex === -1) {
50 | rowIndex = rows.length;
51 | rows.push([]);
52 | }
53 |
54 | rows[rowIndex].push(event);
55 | });
56 |
57 | return rows;
58 | }, [processedEvents]);
59 |
60 | const hasEventsInWeek = useMemo(() => {
61 | return multiDayEvents.some(event => {
62 | const start = parseISO(event.startDate);
63 | const end = parseISO(event.endDate);
64 |
65 | return (
66 | // Event starts within the week
67 | (start >= weekStart && start <= weekEnd) ||
68 | // Event ends within the week
69 | (end >= weekStart && end <= weekEnd) ||
70 | // Event spans the entire week
71 | (start <= weekStart && end >= weekEnd)
72 | );
73 | });
74 | }, [multiDayEvents, weekStart, weekEnd]);
75 |
76 | if (!hasEventsInWeek) {
77 | return null;
78 | }
79 |
80 | return (
81 |
82 |
83 |
84 | {weekDays.map((day, dayIndex) => (
85 |
86 | {eventRows.map((row, rowIndex) => {
87 | const event = row.find(e => e.startIndex <= dayIndex && e.endIndex >= dayIndex);
88 |
89 | if (!event) {
90 | return
;
91 | }
92 |
93 | let position: "first" | "middle" | "last" | "none" = "none";
94 |
95 | if (dayIndex === event.startIndex && dayIndex === event.endIndex) {
96 | position = "none";
97 | } else if (dayIndex === event.startIndex) {
98 | position = "first";
99 | } else if (dayIndex === event.endIndex) {
100 | position = "last";
101 | } else {
102 | position = "middle";
103 | }
104 |
105 | return
;
106 | })}
107 |
108 | ))}
109 |
110 |
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/src/calendar/components/year-view/calendar-year-view.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { addMonths, startOfYear } from "date-fns";
3 |
4 | import { useCalendar } from "@/calendar/contexts/calendar-context";
5 |
6 | import { YearViewMonth } from "@/calendar/components/year-view/year-view-month";
7 |
8 | import type { IEvent } from "@/calendar/interfaces";
9 |
10 | interface IProps {
11 | allEvents: IEvent[];
12 | }
13 |
14 | export function CalendarYearView({ allEvents }: IProps) {
15 | const { selectedDate } = useCalendar();
16 |
17 | const months = useMemo(() => {
18 | const yearStart = startOfYear(selectedDate);
19 | return Array.from({ length: 12 }, (_, i) => addMonths(yearStart, i));
20 | }, [selectedDate]);
21 |
22 | return (
23 |
24 |
25 | {months.map(month => (
26 |
27 | ))}
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/calendar/components/year-view/year-view-day-cell.tsx:
--------------------------------------------------------------------------------
1 | import { isToday } from "date-fns";
2 | import { useRouter } from "next/navigation";
3 |
4 | import { useCalendar } from "@/calendar/contexts/calendar-context";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | import type { IEvent } from "@/calendar/interfaces";
9 |
10 | interface IProps {
11 | day: number;
12 | date: Date;
13 | events: IEvent[];
14 | }
15 |
16 | export function YearViewDayCell({ day, date, events }: IProps) {
17 | const { push } = useRouter();
18 | const { setSelectedDate } = useCalendar();
19 |
20 | const maxIndicators = 3;
21 | const eventCount = events.length;
22 |
23 | const handleClick = () => {
24 | setSelectedDate(date);
25 | push("/day-view");
26 | };
27 |
28 | return (
29 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/src/calendar/components/year-view/year-view-month.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { useRouter } from "next/navigation";
3 | import { format, isSameDay, parseISO, getDaysInMonth, startOfMonth } from "date-fns";
4 |
5 | import { useCalendar } from "@/calendar/contexts/calendar-context";
6 |
7 | import { YearViewDayCell } from "@/calendar/components/year-view/year-view-day-cell";
8 |
9 | import type { IEvent } from "@/calendar/interfaces";
10 |
11 | interface IProps {
12 | month: Date;
13 | events: IEvent[];
14 | }
15 |
16 | export function YearViewMonth({ month, events }: IProps) {
17 | const { push } = useRouter();
18 | const { setSelectedDate } = useCalendar();
19 |
20 | const monthName = format(month, "MMMM");
21 |
22 | const daysInMonth = useMemo(() => {
23 | const totalDays = getDaysInMonth(month);
24 | const firstDay = startOfMonth(month).getDay();
25 |
26 | const days = Array.from({ length: totalDays }, (_, i) => i + 1);
27 | const blanks = Array(firstDay).fill(null);
28 |
29 | return [...blanks, ...days];
30 | }, [month]);
31 |
32 | const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
33 |
34 | const handleClick = () => {
35 | setSelectedDate(new Date(month.getFullYear(), month.getMonth(), 1));
36 | push("/month-view");
37 | };
38 |
39 | return (
40 |
41 |
48 |
49 |
50 |
51 | {weekDays.map((day, index) => (
52 |
53 | {day}
54 |
55 | ))}
56 |
57 |
58 |
59 | {daysInMonth.map((day, index) => {
60 | if (day === null) return
;
61 |
62 | const date = new Date(month.getFullYear(), month.getMonth(), day);
63 | const dayEvents = events.filter(event => isSameDay(parseISO(event.startDate), date) || isSameDay(parseISO(event.endDate), date));
64 |
65 | return
;
66 | })}
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/src/calendar/contexts/calendar-context.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { createContext, useContext, useState } from "react";
4 |
5 | import type { Dispatch, SetStateAction } from "react";
6 | import type { IEvent, IUser } from "@/calendar/interfaces";
7 | import type { TBadgeVariant, TVisibleHours, TWorkingHours } from "@/calendar/types";
8 |
9 | interface ICalendarContext {
10 | selectedDate: Date;
11 | setSelectedDate: (date: Date | undefined) => void;
12 | selectedUserId: IUser["id"] | "all";
13 | setSelectedUserId: (userId: IUser["id"] | "all") => void;
14 | badgeVariant: TBadgeVariant;
15 | setBadgeVariant: (variant: TBadgeVariant) => void;
16 | users: IUser[];
17 | workingHours: TWorkingHours;
18 | setWorkingHours: Dispatch>;
19 | visibleHours: TVisibleHours;
20 | setVisibleHours: Dispatch>;
21 | events: IEvent[];
22 | setLocalEvents: Dispatch>;
23 | }
24 |
25 | const CalendarContext = createContext({} as ICalendarContext);
26 |
27 | const WORKING_HOURS = {
28 | 0: { from: 0, to: 0 },
29 | 1: { from: 8, to: 17 },
30 | 2: { from: 8, to: 17 },
31 | 3: { from: 8, to: 17 },
32 | 4: { from: 8, to: 17 },
33 | 5: { from: 8, to: 17 },
34 | 6: { from: 8, to: 12 },
35 | };
36 |
37 | const VISIBLE_HOURS = { from: 7, to: 18 };
38 |
39 | export function CalendarProvider({ children, users, events }: { children: React.ReactNode; users: IUser[]; events: IEvent[] }) {
40 | const [badgeVariant, setBadgeVariant] = useState("colored");
41 | const [visibleHours, setVisibleHours] = useState(VISIBLE_HOURS);
42 | const [workingHours, setWorkingHours] = useState(WORKING_HOURS);
43 |
44 | const [selectedDate, setSelectedDate] = useState(new Date());
45 | const [selectedUserId, setSelectedUserId] = useState("all");
46 |
47 | // This localEvents doesn't need to exists in a real scenario.
48 | // It's used here just to simulate the update of the events.
49 | // In a real scenario, the events would be updated in the backend
50 | // and the request that fetches the events should be refetched
51 | const [localEvents, setLocalEvents] = useState(events);
52 |
53 | const handleSelectDate = (date: Date | undefined) => {
54 | if (!date) return;
55 | setSelectedDate(date);
56 | };
57 |
58 | return (
59 |
77 | {children}
78 |
79 | );
80 | }
81 |
82 | export function useCalendar(): ICalendarContext {
83 | const context = useContext(CalendarContext);
84 | if (!context) throw new Error("useCalendar must be used within a CalendarProvider.");
85 | return context;
86 | }
87 |
--------------------------------------------------------------------------------
/src/calendar/helpers.ts:
--------------------------------------------------------------------------------
1 | import {
2 | addDays,
3 | addMonths,
4 | addWeeks,
5 | subDays,
6 | subMonths,
7 | subWeeks,
8 | isSameWeek,
9 | isSameDay,
10 | isSameMonth,
11 | startOfWeek,
12 | startOfMonth,
13 | endOfMonth,
14 | endOfWeek,
15 | format,
16 | parseISO,
17 | differenceInMinutes,
18 | eachDayOfInterval,
19 | startOfDay,
20 | differenceInDays,
21 | endOfYear,
22 | startOfYear,
23 | subYears,
24 | addYears,
25 | isSameYear,
26 | isWithinInterval,
27 | } from "date-fns";
28 |
29 | import type { ICalendarCell, IEvent } from "@/calendar/interfaces";
30 | import type { TCalendarView, TVisibleHours, TWorkingHours } from "@/calendar/types";
31 |
32 | // ================ Header helper functions ================ //
33 |
34 | export function rangeText(view: TCalendarView, date: Date) {
35 | const formatString = "MMM d, yyyy";
36 | let start: Date;
37 | let end: Date;
38 |
39 | switch (view) {
40 | case "agenda":
41 | start = startOfMonth(date);
42 | end = endOfMonth(date);
43 | break;
44 | case "year":
45 | start = startOfYear(date);
46 | end = endOfYear(date);
47 | break;
48 | case "month":
49 | start = startOfMonth(date);
50 | end = endOfMonth(date);
51 | break;
52 | case "week":
53 | start = startOfWeek(date);
54 | end = endOfWeek(date);
55 | break;
56 | case "day":
57 | return format(date, formatString);
58 | default:
59 | return "Error while formatting ";
60 | }
61 |
62 | return `${format(start, formatString)} - ${format(end, formatString)}`;
63 | }
64 |
65 | export function navigateDate(date: Date, view: TCalendarView, direction: "previous" | "next"): Date {
66 | const operations = {
67 | agenda: direction === "next" ? addMonths : subMonths,
68 | year: direction === "next" ? addYears : subYears,
69 | month: direction === "next" ? addMonths : subMonths,
70 | week: direction === "next" ? addWeeks : subWeeks,
71 | day: direction === "next" ? addDays : subDays,
72 | };
73 |
74 | return operations[view](date, 1);
75 | }
76 |
77 | export function getEventsCount(events: IEvent[], date: Date, view: TCalendarView): number {
78 | const compareFns = {
79 | agenda: isSameMonth,
80 | year: isSameYear,
81 | day: isSameDay,
82 | week: isSameWeek,
83 | month: isSameMonth,
84 | };
85 |
86 | return events.filter(event => compareFns[view](new Date(event.startDate), date)).length;
87 | }
88 |
89 | // ================ Week and day view helper functions ================ //
90 |
91 | export function getCurrentEvents(events: IEvent[]) {
92 | const now = new Date();
93 | return events.filter(event => isWithinInterval(now, { start: parseISO(event.startDate), end: parseISO(event.endDate) })) || null;
94 | }
95 |
96 | export function groupEvents(dayEvents: IEvent[]) {
97 | const sortedEvents = dayEvents.sort((a, b) => parseISO(a.startDate).getTime() - parseISO(b.startDate).getTime());
98 | const groups: IEvent[][] = [];
99 |
100 | for (const event of sortedEvents) {
101 | const eventStart = parseISO(event.startDate);
102 |
103 | let placed = false;
104 | for (const group of groups) {
105 | const lastEventInGroup = group[group.length - 1];
106 | const lastEventEnd = parseISO(lastEventInGroup.endDate);
107 |
108 | if (eventStart >= lastEventEnd) {
109 | group.push(event);
110 | placed = true;
111 | break;
112 | }
113 | }
114 |
115 | if (!placed) groups.push([event]);
116 | }
117 |
118 | return groups;
119 | }
120 |
121 | export function getEventBlockStyle(event: IEvent, day: Date, groupIndex: number, groupSize: number, visibleHoursRange?: { from: number; to: number }) {
122 | const startDate = parseISO(event.startDate);
123 | const dayStart = new Date(day.setHours(0, 0, 0, 0));
124 | const eventStart = startDate < dayStart ? dayStart : startDate;
125 | const startMinutes = differenceInMinutes(eventStart, dayStart);
126 |
127 | let top;
128 |
129 | if (visibleHoursRange) {
130 | const visibleStartMinutes = visibleHoursRange.from * 60;
131 | const visibleEndMinutes = visibleHoursRange.to * 60;
132 | const visibleRangeMinutes = visibleEndMinutes - visibleStartMinutes;
133 | top = ((startMinutes - visibleStartMinutes) / visibleRangeMinutes) * 100;
134 | } else {
135 | top = (startMinutes / 1440) * 100;
136 | }
137 |
138 | const width = 100 / groupSize;
139 | const left = groupIndex * width;
140 |
141 | return { top: `${top}%`, width: `${width}%`, left: `${left}%` };
142 | }
143 |
144 | export function isWorkingHour(day: Date, hour: number, workingHours: TWorkingHours) {
145 | const dayIndex = day.getDay() as keyof typeof workingHours;
146 | const dayHours = workingHours[dayIndex];
147 | return hour >= dayHours.from && hour < dayHours.to;
148 | }
149 |
150 | export function getVisibleHours(visibleHours: TVisibleHours, singleDayEvents: IEvent[]) {
151 | let earliestEventHour = visibleHours.from;
152 | let latestEventHour = visibleHours.to;
153 |
154 | singleDayEvents.forEach(event => {
155 | const startHour = parseISO(event.startDate).getHours();
156 | const endTime = parseISO(event.endDate);
157 | const endHour = endTime.getHours() + (endTime.getMinutes() > 0 ? 1 : 0);
158 | if (startHour < earliestEventHour) earliestEventHour = startHour;
159 | if (endHour > latestEventHour) latestEventHour = endHour;
160 | });
161 |
162 | latestEventHour = Math.min(latestEventHour, 24);
163 |
164 | const hours = Array.from({ length: latestEventHour - earliestEventHour }, (_, i) => i + earliestEventHour);
165 |
166 | return { hours, earliestEventHour, latestEventHour };
167 | }
168 |
169 | // ================ Month view helper functions ================ //
170 |
171 | export function getCalendarCells(selectedDate: Date): ICalendarCell[] {
172 | const currentYear = selectedDate.getFullYear();
173 | const currentMonth = selectedDate.getMonth();
174 |
175 | const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
176 | const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
177 |
178 | const daysInMonth = getDaysInMonth(currentYear, currentMonth);
179 | const firstDayOfMonth = getFirstDayOfMonth(currentYear, currentMonth);
180 | const daysInPrevMonth = getDaysInMonth(currentYear, currentMonth - 1);
181 | const totalDays = firstDayOfMonth + daysInMonth;
182 |
183 | const prevMonthCells = Array.from({ length: firstDayOfMonth }, (_, i) => ({
184 | day: daysInPrevMonth - firstDayOfMonth + i + 1,
185 | currentMonth: false,
186 | date: new Date(currentYear, currentMonth - 1, daysInPrevMonth - firstDayOfMonth + i + 1),
187 | }));
188 |
189 | const currentMonthCells = Array.from({ length: daysInMonth }, (_, i) => ({
190 | day: i + 1,
191 | currentMonth: true,
192 | date: new Date(currentYear, currentMonth, i + 1),
193 | }));
194 |
195 | const nextMonthCells = Array.from({ length: (7 - (totalDays % 7)) % 7 }, (_, i) => ({
196 | day: i + 1,
197 | currentMonth: false,
198 | date: new Date(currentYear, currentMonth + 1, i + 1),
199 | }));
200 |
201 | return [...prevMonthCells, ...currentMonthCells, ...nextMonthCells];
202 | }
203 |
204 | export function calculateMonthEventPositions(multiDayEvents: IEvent[], singleDayEvents: IEvent[], selectedDate: Date) {
205 | const monthStart = startOfMonth(selectedDate);
206 | const monthEnd = endOfMonth(selectedDate);
207 |
208 | const eventPositions: { [key: string]: number } = {};
209 | const occupiedPositions: { [key: string]: boolean[] } = {};
210 |
211 | eachDayOfInterval({ start: monthStart, end: monthEnd }).forEach(day => {
212 | occupiedPositions[day.toISOString()] = [false, false, false];
213 | });
214 |
215 | const sortedEvents = [
216 | ...multiDayEvents.sort((a, b) => {
217 | const aDuration = differenceInDays(parseISO(a.endDate), parseISO(a.startDate));
218 | const bDuration = differenceInDays(parseISO(b.endDate), parseISO(b.startDate));
219 | return bDuration - aDuration || parseISO(a.startDate).getTime() - parseISO(b.startDate).getTime();
220 | }),
221 | ...singleDayEvents.sort((a, b) => parseISO(a.startDate).getTime() - parseISO(b.startDate).getTime()),
222 | ];
223 |
224 | sortedEvents.forEach(event => {
225 | const eventStart = parseISO(event.startDate);
226 | const eventEnd = parseISO(event.endDate);
227 | const eventDays = eachDayOfInterval({
228 | start: eventStart < monthStart ? monthStart : eventStart,
229 | end: eventEnd > monthEnd ? monthEnd : eventEnd,
230 | });
231 |
232 | let position = -1;
233 |
234 | for (let i = 0; i < 3; i++) {
235 | if (
236 | eventDays.every(day => {
237 | const dayPositions = occupiedPositions[startOfDay(day).toISOString()];
238 | return dayPositions && !dayPositions[i];
239 | })
240 | ) {
241 | position = i;
242 | break;
243 | }
244 | }
245 |
246 | if (position !== -1) {
247 | eventDays.forEach(day => {
248 | const dayKey = startOfDay(day).toISOString();
249 | occupiedPositions[dayKey][position] = true;
250 | });
251 | eventPositions[event.id] = position;
252 | }
253 | });
254 |
255 | return eventPositions;
256 | }
257 |
258 | export function getMonthCellEvents(date: Date, events: IEvent[], eventPositions: Record) {
259 | const eventsForDate = events.filter(event => {
260 | const eventStart = parseISO(event.startDate);
261 | const eventEnd = parseISO(event.endDate);
262 | return (date >= eventStart && date <= eventEnd) || isSameDay(date, eventStart) || isSameDay(date, eventEnd);
263 | });
264 |
265 | return eventsForDate
266 | .map(event => ({
267 | ...event,
268 | position: eventPositions[event.id] ?? -1,
269 | isMultiDay: event.startDate !== event.endDate,
270 | }))
271 | .sort((a, b) => {
272 | if (a.isMultiDay && !b.isMultiDay) return -1;
273 | if (!a.isMultiDay && b.isMultiDay) return 1;
274 | return a.position - b.position;
275 | });
276 | }
277 |
--------------------------------------------------------------------------------
/src/calendar/hooks/use-update-event.ts:
--------------------------------------------------------------------------------
1 | import { useCalendar } from "@/calendar/contexts/calendar-context";
2 |
3 | import type { IEvent } from "@/calendar/interfaces";
4 |
5 | export function useUpdateEvent() {
6 | const { setLocalEvents } = useCalendar();
7 |
8 | // This is just and example, in a real scenario
9 | // you would call an API to update the event
10 | const updateEvent = (event: IEvent) => {
11 | const newEvent: IEvent = event;
12 |
13 | newEvent.startDate = new Date(event.startDate).toISOString();
14 | newEvent.endDate = new Date(event.endDate).toISOString();
15 |
16 | setLocalEvents(prev => {
17 | const index = prev.findIndex(e => e.id === event.id);
18 | if (index === -1) return prev;
19 | return [...prev.slice(0, index), newEvent, ...prev.slice(index + 1)];
20 | });
21 | };
22 |
23 | return { updateEvent };
24 | }
25 |
--------------------------------------------------------------------------------
/src/calendar/interfaces.ts:
--------------------------------------------------------------------------------
1 | import type { TEventColor } from "@/calendar/types";
2 |
3 | export interface IUser {
4 | id: string;
5 | name: string;
6 | picturePath: string | null;
7 | }
8 |
9 | export interface IEvent {
10 | id: number;
11 | startDate: string;
12 | endDate: string;
13 | title: string;
14 | color: TEventColor;
15 | description: string;
16 | user: IUser;
17 | }
18 |
19 | export interface ICalendarCell {
20 | day: number;
21 | currentMonth: boolean;
22 | date: Date;
23 | }
24 |
--------------------------------------------------------------------------------
/src/calendar/mocks.ts:
--------------------------------------------------------------------------------
1 | import type { TEventColor } from "@/calendar/types";
2 | import type { IEvent, IUser } from "@/calendar/interfaces";
3 |
4 | // ================================== //
5 |
6 | export const USERS_MOCK: IUser[] = [
7 | {
8 | id: "dd503cf9-6c38-43cf-94cc-0d4032e2f77a",
9 | name: "Leonardo Ramos",
10 | picturePath: null,
11 | },
12 | {
13 | id: "f3b035ac-49f7-4e92-a715-35680bf63175",
14 | name: "Michael Doe",
15 | picturePath: null,
16 | },
17 | {
18 | id: "3e36ea6e-78f3-40dd-ab8c-a6c737c3c422",
19 | name: "Alice Johnson",
20 | picturePath: null,
21 | },
22 | {
23 | id: "a7aff6bd-a50a-4d6a-ab57-76f76bb27cf5",
24 | name: "Robert Smith",
25 | picturePath: null,
26 | },
27 | ];
28 |
29 | const COLORS: TEventColor[] = ["blue", "green", "red", "yellow", "purple", "orange", "gray"];
30 |
31 | const EVENTS = [
32 | "Doctor's appointment",
33 | "Dental cleaning",
34 | "Eye exam",
35 | "Therapy session",
36 | "Business meeting",
37 | "Team stand-up",
38 | "Project deadline",
39 | "Weekly report submission",
40 | "Client presentation",
41 | "Marketing strategy review",
42 | "Networking event",
43 | "Sales call",
44 | "Investor pitch",
45 | "Board meeting",
46 | "Employee training",
47 | "Performance review",
48 | "One-on-one meeting",
49 | "Lunch with a colleague",
50 | "HR interview",
51 | "Conference call",
52 | "Web development sprint planning",
53 | "Software deployment",
54 | "Code review",
55 | "QA testing session",
56 | "Cybersecurity audit",
57 | "Server maintenance",
58 | "API integration update",
59 | "Data backup",
60 | "Cloud migration",
61 | "System upgrade",
62 | "Content planning session",
63 | "Product launch",
64 | "Customer support review",
65 | "Team building activity",
66 | "Legal consultation",
67 | "Budget review",
68 | "Financial planning session",
69 | "Tax filing deadline",
70 | "Investor relations update",
71 | "Partnership negotiation",
72 | "Medical check-up",
73 | "Vaccination appointment",
74 | "Blood donation",
75 | "Gym workout",
76 | "Yoga class",
77 | "Physical therapy session",
78 | "Nutrition consultation",
79 | "Personal trainer session",
80 | "Parent-teacher meeting",
81 | "School open house",
82 | "College application deadline",
83 | "Final exam",
84 | "Graduation ceremony",
85 | "Job interview",
86 | "Internship orientation",
87 | "Office relocation",
88 | "Business trip",
89 | "Flight departure",
90 | "Hotel check-in",
91 | "Vacation planning",
92 | "Birthday party",
93 | "Wedding anniversary",
94 | "Family reunion",
95 | "Housewarming party",
96 | "Community volunteer work",
97 | "Charity fundraiser",
98 | "Religious service",
99 | "Concert attendance",
100 | "Theater play",
101 | "Movie night",
102 | "Sporting event",
103 | "Football match",
104 | "Basketball game",
105 | "Tennis practice",
106 | "Marathon training",
107 | "Cycling event",
108 | "Fishing trip",
109 | "Camping weekend",
110 | "Hiking expedition",
111 | "Photography session",
112 | "Art workshop",
113 | "Cooking class",
114 | "Book club meeting",
115 | "Grocery shopping",
116 | "Car maintenance",
117 | "Home renovation meeting",
118 | ];
119 |
120 | // This was generated by AI -- minus the part where I added my wedding as an "easter egg" :)
121 | const mockGenerator = (numberOfEvents: number): IEvent[] => {
122 | const result: IEvent[] = [
123 | {
124 | id: 1204,
125 | startDate: new Date("2025-09-20T00:00:00-03:00").toISOString(),
126 | endDate: new Date("2025-09-20T23:59:00-03:00").toISOString(),
127 | title: "My wedding :)",
128 | color: "red",
129 | description: "Can't wait to see the most beautiful woman in that dress!",
130 | user: USERS_MOCK[0],
131 | },
132 | ];
133 |
134 | let currentId = 1;
135 |
136 | const randomUser = USERS_MOCK[Math.floor(Math.random() * USERS_MOCK.length)];
137 |
138 | // Date range: 30 days before and after now
139 | const now = new Date();
140 | const startRange = new Date(now);
141 | startRange.setDate(now.getDate() - 30);
142 | const endRange = new Date(now);
143 | endRange.setDate(now.getDate() + 30);
144 |
145 | // Create an event happening now
146 | const currentEvent = {
147 | id: currentId++,
148 | startDate: new Date(now.getTime() - 30 * 60000).toISOString(),
149 | endDate: new Date(now.getTime() + 30 * 60000).toISOString(),
150 | title: EVENTS[Math.floor(Math.random() * EVENTS.length)],
151 | color: COLORS[Math.floor(Math.random() * COLORS.length)],
152 | description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
153 | user: randomUser,
154 | };
155 |
156 | // Only add the current event if it's not on September 20th
157 | if (now.getMonth() !== 8 || now.getDate() !== 20) {
158 | // Month is 0-indexed (8 = September)
159 | result.push(currentEvent);
160 | }
161 |
162 | // Generate the remaining events
163 | let i = 0;
164 | let attempts = 0;
165 | const maxAttempts = numberOfEvents * 3; // Prevent infinite loop with a reasonable max attempts
166 |
167 | while (i < numberOfEvents - 1 && attempts < maxAttempts) {
168 | attempts++;
169 |
170 | // Determine if this is a multi-day event (10% chance)
171 | const isMultiDay = Math.random() < 0.1;
172 |
173 | const startDate = new Date(startRange.getTime() + Math.random() * (endRange.getTime() - startRange.getTime()));
174 |
175 | // Skip if the date is September 20th
176 | if (startDate.getMonth() === 8 && startDate.getDate() === 20) {
177 | continue;
178 | }
179 |
180 | // Set time between 8 AM and 8 PM
181 | startDate.setHours(8 + Math.floor(Math.random() * 8), Math.floor(Math.random() * 4) * 15, 0, 0);
182 |
183 | const endDate = new Date(startDate);
184 |
185 | if (isMultiDay) {
186 | // Multi-day event: Add 1-4 days
187 | const additionalDays = Math.floor(Math.random() * 4) + 1;
188 | endDate.setDate(startDate.getDate() + additionalDays);
189 |
190 | // Ensure multi-day events don't cross September 20th
191 | const endMonth = endDate.getMonth();
192 | const endDay = endDate.getDate();
193 | const startMonth = startDate.getMonth();
194 | const startDay = startDate.getDate();
195 |
196 | // Check if event spans across September 20th
197 | if (
198 | (startMonth === 8 && startDay < 20 && (endMonth > 8 || (endMonth === 8 && endDay >= 20))) ||
199 | (endMonth === 8 && endDay >= 20 && (startMonth < 8 || (startMonth === 8 && startDay < 20)))
200 | ) {
201 | continue;
202 | }
203 |
204 | endDate.setHours(8 + Math.floor(Math.random() * 12), Math.floor(Math.random() * 4) * 15, 0, 0);
205 | } else {
206 | const durationMinutes = (Math.floor(Math.random() * 11) + 2) * 15; // 30 to 180 minutes, multiple of 15
207 | endDate.setTime(endDate.getTime() + durationMinutes * 60 * 1000);
208 | }
209 |
210 | result.push({
211 | id: currentId++,
212 | startDate: startDate.toISOString(),
213 | endDate: endDate.toISOString(),
214 | title: EVENTS[Math.floor(Math.random() * EVENTS.length)],
215 | color: COLORS[Math.floor(Math.random() * COLORS.length)],
216 | description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
217 | user: USERS_MOCK[Math.floor(Math.random() * USERS_MOCK.length)],
218 | });
219 |
220 | i++;
221 | }
222 |
223 | return result;
224 | };
225 |
226 | export const CALENDAR_ITENS_MOCK: IEvent[] = mockGenerator(80);
227 |
--------------------------------------------------------------------------------
/src/calendar/requests.ts:
--------------------------------------------------------------------------------
1 | import { CALENDAR_ITENS_MOCK, USERS_MOCK } from "@/calendar/mocks";
2 |
3 | export const getEvents = async () => {
4 | // TO DO: implement this
5 | // Increase the delay to better see the loading state
6 | // await new Promise(resolve => setTimeout(resolve, 800));
7 | return CALENDAR_ITENS_MOCK;
8 | };
9 |
10 | export const getUsers = async () => {
11 | // TO DO: implement this
12 | // Increase the delay to better see the loading state
13 | // await new Promise(resolve => setTimeout(resolve, 800));
14 | return USERS_MOCK;
15 | };
16 |
--------------------------------------------------------------------------------
/src/calendar/schemas.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const eventSchema = z.object({
4 | user: z.string(),
5 | title: z.string().min(1, "Title is required"),
6 | description: z.string().min(1, "Description is required"),
7 | startDate: z.date({ required_error: "Start date is required" }),
8 | startTime: z.object({ hour: z.number(), minute: z.number() }, { required_error: "Start time is required" }),
9 | endDate: z.date({ required_error: "End date is required" }),
10 | endTime: z.object({ hour: z.number(), minute: z.number() }, { required_error: "End time is required" }),
11 | color: z.enum(["blue", "green", "red", "yellow", "purple", "orange", "gray"], { required_error: "Color is required" }),
12 | });
13 |
14 | export type TEventFormData = z.infer;
15 |
--------------------------------------------------------------------------------
/src/calendar/types.ts:
--------------------------------------------------------------------------------
1 | export type TCalendarView = "day" | "week" | "month" | "year" | "agenda";
2 | export type TEventColor = "blue" | "green" | "red" | "yellow" | "purple" | "orange" | "gray";
3 | export type TBadgeVariant = "dot" | "colored" | "mixed";
4 | export type TWorkingHours = { [key: number]: { from: number; to: number } };
5 | export type TVisibleHours = { from: number; to: number };
6 |
--------------------------------------------------------------------------------
/src/components/layout/change-theme.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Moon, Sun } from "lucide-react";
4 | import { useEffect, useState } from "react";
5 |
6 | import { Button } from "@/components/ui/button";
7 | import { Skeleton } from "@/components/ui/skeleton";
8 |
9 | import { setTheme } from "@/cookies/set";
10 |
11 | export function ToggleTheme() {
12 | const [currentTheme, setCurrentTheme] = useState<"light" | "dark">();
13 |
14 | useEffect(() => {
15 | const currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
16 | setCurrentTheme(currentTheme);
17 | }, []);
18 |
19 | const toggleTheme = () => {
20 | const newTheme = currentTheme === "light" ? "dark" : "light";
21 | setTheme(newTheme);
22 | setCurrentTheme(newTheme);
23 | };
24 |
25 | if (!currentTheme) return ;
26 |
27 | return (
28 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/layout/header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { ArrowUpRight, Calendar } from "lucide-react";
3 |
4 | import { ToggleTheme } from "@/components/layout/change-theme";
5 | import { Button } from "@/components/ui/button";
6 |
7 | export function Header() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
Big calendar
18 |
19 | Built with Next.js and Tailwind by{" "}
20 |
25 | lramos33
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
38 | View on GitHub
39 |
40 |
41 |
42 |
43 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 | import { ChevronDown } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Accordion = AccordionPrimitive.Root
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
20 | ))
21 | AccordionItem.displayName = "AccordionItem"
22 |
23 | const AccordionTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, children, ...props }, ref) => (
27 |
28 | svg]:rotate-180",
32 | className
33 | )}
34 | {...props}
35 | >
36 | {children}
37 |
38 |
39 |
40 | ))
41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
42 |
43 | const AccordionContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, children, ...props }, ref) => (
47 |
52 | {children}
53 |
54 | ))
55 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
56 |
57 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
58 |
--------------------------------------------------------------------------------
/src/components/ui/avatar-group.tsx:
--------------------------------------------------------------------------------
1 | import { cloneElement, Children, forwardRef, useMemo } from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | import type { ElementRef, HTMLAttributes, ReactElement } from "react";
6 |
7 | // ================================== //
8 |
9 | type TAvatarGroupRef = ElementRef<"div">;
10 | type TAvatarGroupProps = HTMLAttributes & { max?: number; spacing?: number };
11 |
12 | const AvatarGroup = forwardRef(({ className, children, max = 1, spacing = 10, ...props }, ref) => {
13 | const avatarItems = Children.toArray(children) as ReactElement[];
14 |
15 | const renderContent = useMemo(() => {
16 | return (
17 | <>
18 | {avatarItems.slice(0, max).map((child, index) => {
19 | return cloneElement(child, {
20 | className: cn(child.props.className, "border-2 border-background"),
21 | style: { marginLeft: index === 0 ? 0 : -spacing, ...child.props.style },
22 | });
23 | })}
24 |
25 | {avatarItems.length > max && (
26 |
30 |
+{avatarItems.length - max}
31 |
32 | )}
33 | >
34 | );
35 | }, [avatarItems, max, spacing]);
36 |
37 | return (
38 |
39 | {renderContent}
40 |
41 | );
42 | });
43 |
44 | AvatarGroup.displayName = "AvatarGroup";
45 |
46 | // ================================== //
47 |
48 | export { AvatarGroup };
49 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Avatar = React.forwardRef, React.ComponentPropsWithoutRef>(
9 | ({ className, ...props }, ref) => (
10 |
11 | )
12 | );
13 | Avatar.displayName = AvatarPrimitive.Root.displayName;
14 |
15 | const AvatarImage = React.forwardRef, React.ComponentPropsWithoutRef>(
16 | ({ className, ...props }, ref) =>
17 | );
18 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
19 |
20 | const AvatarFallback = React.forwardRef, React.ComponentPropsWithoutRef>(
21 | ({ className, ...props }, ref) => (
22 |
23 | )
24 | );
25 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
26 |
27 | export { Avatar, AvatarImage, AvatarFallback };
28 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
12 | secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
13 | destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
14 | outline: "text-foreground",
15 | },
16 | },
17 | defaultVariants: {
18 | variant: "default",
19 | },
20 | }
21 | );
22 |
23 | export interface BadgeProps extends React.HTMLAttributes, VariantProps {}
24 |
25 | function Badge({ className, variant, ...props }: BadgeProps) {
26 | return ;
27 | }
28 |
29 | export { Badge, badgeVariants };
30 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
13 | destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
14 | outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
15 | secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
16 | ghost: "hover:bg-accent hover:text-accent-foreground",
17 | link: "text-primary underline-offset-4 hover:underline",
18 | },
19 | size: {
20 | default: "h-9 px-4 py-2",
21 | sm: "h-8 rounded-md px-3 text-xs",
22 | lg: "h-10 rounded-md px-8",
23 | icon: "size-9",
24 | },
25 | },
26 | defaultVariants: {
27 | variant: "default",
28 | size: "default",
29 | },
30 | }
31 | );
32 |
33 | export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps {
34 | asChild?: boolean;
35 | }
36 |
37 | const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
38 | const Comp = asChild ? Slot : "button";
39 | return ;
40 | });
41 | Button.displayName = "Button";
42 |
43 | export { Button, buttonVariants };
44 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DialogPrimitive from "@radix-ui/react-dialog";
5 | import { X } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = DialogPrimitive.Portal;
14 |
15 | const DialogClose = DialogPrimitive.Close;
16 |
17 | const DialogOverlay = React.forwardRef, React.ComponentPropsWithoutRef>(
18 | ({ className, ...props }, ref) => (
19 |
27 | )
28 | );
29 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
30 |
31 | const DialogContent = React.forwardRef, React.ComponentPropsWithoutRef>(
32 | ({ className, children, ...props }, ref) => (
33 |
34 |
35 |
43 | {children}
44 |
45 |
46 | Close
47 |
48 |
49 |
50 | )
51 | );
52 | DialogContent.displayName = DialogPrimitive.Content.displayName;
53 |
54 | function DialogHeader({ className, ...props }: React.HTMLAttributes) {
55 | return ;
56 | }
57 | DialogHeader.displayName = "DialogHeader";
58 |
59 | function DialogFooter({ className, ...props }: React.HTMLAttributes) {
60 | return ;
61 | }
62 | DialogFooter.displayName = "DialogFooter";
63 |
64 | const DialogTitle = React.forwardRef, React.ComponentPropsWithoutRef>(
65 | ({ className, ...props }, ref) => (
66 |
67 | )
68 | );
69 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
70 |
71 | const DialogDescription = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => );
75 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
76 |
77 | export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
78 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import type * as LabelPrimitive from "@radix-ui/react-label";
5 | import { Slot } from "@radix-ui/react-slot";
6 | import { Controller, FormProvider, useFormContext, type ControllerProps, type FieldPath, type FieldValues } from "react-hook-form";
7 |
8 | import { cn } from "@/lib/utils";
9 | import { Label } from "@/components/ui/label";
10 |
11 | const Form = FormProvider;
12 |
13 | type FormFieldContextValue = FieldPath> = {
14 | name: TName;
15 | };
16 |
17 | const FormFieldContext = React.createContext({} as FormFieldContextValue);
18 |
19 | function FormField = FieldPath>({
20 | ...props
21 | }: ControllerProps) {
22 | return (
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | const useFormField = () => {
30 | const fieldContext = React.useContext(FormFieldContext);
31 | const itemContext = React.useContext(FormItemContext);
32 | const { getFieldState, formState } = useFormContext();
33 |
34 | const fieldState = getFieldState(fieldContext.name, formState);
35 |
36 | if (!fieldContext) {
37 | throw new Error("useFormField should be used within ");
38 | }
39 |
40 | const { id } = itemContext;
41 |
42 | return {
43 | id,
44 | name: fieldContext.name,
45 | formItemId: `${id}-form-item`,
46 | formDescriptionId: `${id}-form-item-description`,
47 | formMessageId: `${id}-form-item-message`,
48 | ...fieldState,
49 | };
50 | };
51 |
52 | type FormItemContextValue = {
53 | id: string;
54 | };
55 |
56 | const FormItemContext = React.createContext({} as FormItemContextValue);
57 |
58 | const FormItem = React.forwardRef>(({ className, ...props }, ref) => {
59 | const id = React.useId();
60 |
61 | return (
62 |
63 |
64 |
65 | );
66 | });
67 | FormItem.displayName = "FormItem";
68 |
69 | const FormLabel = React.forwardRef, React.ComponentPropsWithoutRef>(
70 | ({ className, ...props }, ref) => {
71 | const { error, formItemId } = useFormField();
72 |
73 | return ;
74 | }
75 | );
76 | FormLabel.displayName = "FormLabel";
77 |
78 | const FormControl = React.forwardRef, React.ComponentPropsWithoutRef>(({ ...props }, ref) => {
79 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
80 |
81 | return (
82 |
89 | );
90 | });
91 | FormControl.displayName = "FormControl";
92 |
93 | const FormDescription = React.forwardRef>(({ className, ...props }, ref) => {
94 | const { formDescriptionId } = useFormField();
95 |
96 | return ;
97 | });
98 | FormDescription.displayName = "FormDescription";
99 |
100 | const FormMessage = React.forwardRef>(({ className, children, ...props }, ref) => {
101 | const { error, formMessageId } = useFormField();
102 | const body = error ? String(error?.message ?? "") : children;
103 |
104 | if (!body) {
105 | return null;
106 | }
107 |
108 | return (
109 |
110 | {body}
111 |
112 | );
113 | });
114 | FormMessage.displayName = "FormMessage";
115 |
116 | export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
117 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Input = React.forwardRef>(({ className, type, ...props }, ref) => {
6 | return (
7 |
16 | );
17 | });
18 | Input.displayName = "Input";
19 |
20 | export { Input };
21 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as LabelPrimitive from "@radix-ui/react-label";
5 | import { cva, type VariantProps } from "class-variance-authority";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef & VariantProps
14 | >(({ className, ...props }, ref) => );
15 | Label.displayName = LabelPrimitive.Root.displayName;
16 |
17 | export { Label };
18 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as PopoverPrimitive from "@radix-ui/react-popover";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Popover = PopoverPrimitive.Root;
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger;
11 |
12 | const PopoverAnchor = PopoverPrimitive.Anchor;
13 |
14 | const PopoverContent = React.forwardRef, React.ComponentPropsWithoutRef>(
15 | ({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | )
29 | );
30 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
31 |
32 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
33 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const ScrollArea = React.forwardRef, React.ComponentPropsWithoutRef>(
9 | ({ className, children, ...props }, ref) => (
10 |
11 | {children}
12 |
13 |
14 |
15 | )
16 | );
17 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
18 |
19 | const ScrollBar = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef
22 | >(({ className, orientation = "vertical", ...props }, ref) => (
23 |
34 |
35 |
36 | ));
37 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
38 |
39 | export { ScrollArea, ScrollBar };
40 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SelectPrimitive from "@radix-ui/react-select";
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Select = SelectPrimitive.Root;
10 |
11 | const SelectGroup = SelectPrimitive.Group;
12 |
13 | const SelectValue = SelectPrimitive.Value;
14 |
15 | const SelectTrigger = React.forwardRef, React.ComponentPropsWithoutRef>(
16 | ({ className, children, ...props }, ref) => (
17 | span]:line-clamp-1",
21 | className
22 | )}
23 | {...props}
24 | >
25 | {children}
26 |
27 |
28 |
29 |
30 | )
31 | );
32 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
33 |
34 | const SelectScrollUpButton = React.forwardRef<
35 | React.ElementRef,
36 | React.ComponentPropsWithoutRef
37 | >(({ className, ...props }, ref) => (
38 |
39 |
40 |
41 | ));
42 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
43 |
44 | const SelectScrollDownButton = React.forwardRef<
45 | React.ElementRef,
46 | React.ComponentPropsWithoutRef
47 | >(({ className, ...props }, ref) => (
48 |
49 |
50 |
51 | ));
52 | SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
53 |
54 | const SelectContent = React.forwardRef, React.ComponentPropsWithoutRef>(
55 | ({ className, children, position = "popper", ...props }, ref) => (
56 |
57 |
68 |
69 |
72 | {children}
73 |
74 |
75 |
76 |
77 | )
78 | );
79 | SelectContent.displayName = SelectPrimitive.Content.displayName;
80 |
81 | const SelectLabel = React.forwardRef, React.ComponentPropsWithoutRef>(
82 | ({ className, ...props }, ref) =>
83 | );
84 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
85 |
86 | const SelectItem = React.forwardRef, React.ComponentPropsWithoutRef>(
87 | ({ className, children, ...props }, ref) => (
88 |
96 |
97 |
98 |
99 |
100 |
101 | {children}
102 |
103 | )
104 | );
105 | SelectItem.displayName = SelectPrimitive.Item.displayName;
106 |
107 | const SelectSeparator = React.forwardRef, React.ComponentPropsWithoutRef>(
108 | ({ className, ...props }, ref) =>
109 | );
110 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
111 |
112 | export {
113 | Select,
114 | SelectGroup,
115 | SelectValue,
116 | SelectTrigger,
117 | SelectContent,
118 | SelectLabel,
119 | SelectItem,
120 | SelectSeparator,
121 | SelectScrollUpButton,
122 | SelectScrollDownButton,
123 | };
124 |
--------------------------------------------------------------------------------
/src/components/ui/single-calendar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { DayPicker } from "react-day-picker";
5 | import { ChevronLeft, ChevronRight } from "lucide-react";
6 |
7 | import { buttonVariants } from "@/components/ui/button";
8 |
9 | import { cn } from "@/lib/utils";
10 |
11 | import type { DayPickerSingleProps } from "react-day-picker";
12 |
13 | function SingleCalendar({ className, classNames, showOutsideDays = true, selected, ...props }: DayPickerSingleProps) {
14 | const [currentMonth, setCurrentMonth] = React.useState(selected instanceof Date ? selected : undefined);
15 |
16 | return (
17 | ,
53 | IconRight: ({ className, ...props }) => ,
54 | }}
55 | {...props}
56 | />
57 | );
58 | }
59 | SingleCalendar.displayName = "Calendar";
60 |
61 | export { SingleCalendar };
62 |
--------------------------------------------------------------------------------
/src/components/ui/single-day-picker.tsx:
--------------------------------------------------------------------------------
1 | import { format } from "date-fns";
2 |
3 | import { useDisclosure } from "@/hooks/use-disclosure";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import { SingleCalendar } from "@/components/ui/single-calendar";
7 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
8 |
9 | import { cn } from "@/lib/utils";
10 |
11 | import type { ButtonHTMLAttributes } from "react";
12 |
13 | // ================================== //
14 |
15 | type TProps = Omit, "onSelect" | "value"> & {
16 | onSelect: (value: Date | undefined) => void;
17 | value?: Date | undefined;
18 | placeholder: string;
19 | labelVariant?: "P" | "PP" | "PPP";
20 | };
21 |
22 | function SingleDayPicker({ id, onSelect, className, placeholder, labelVariant = "PPP", value, ...props }: TProps) {
23 | const { isOpen, onClose, onToggle } = useDisclosure();
24 |
25 | const handleSelect = (date: Date | undefined) => {
26 | onSelect(date);
27 | onClose();
28 | };
29 |
30 | return (
31 |
32 |
33 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | // ================================== //
52 |
53 | export { SingleDayPicker };
54 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Textarea = React.forwardRef>(({ className, ...props }, ref) => {
6 | return (
7 |
15 | );
16 | });
17 | Textarea.displayName = "Textarea";
18 |
19 | export { Textarea };
20 |
--------------------------------------------------------------------------------
/src/components/ui/time-input.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from "react";
2 | import { DateInput, DateSegment, TimeField } from "react-aria-components";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | import type { TimeFieldProps, TimeValue } from "react-aria-components";
7 |
8 | // ================================== //
9 |
10 | type TTimeInputRef = HTMLDivElement;
11 | type TTimeInputProps = Omit, "isDisabled" | "isInvalid"> & {
12 | readonly dateInputClassName?: string;
13 | readonly segmentClassName?: string;
14 | readonly disabled?: boolean;
15 | readonly "data-invalid"?: boolean;
16 | };
17 |
18 | const TimeInput = forwardRef(
19 | ({ className, dateInputClassName, segmentClassName, disabled, "data-invalid": dataInvalid, ...props }, ref) => {
20 | return (
21 |
30 |
38 | {segment => (
39 |
49 | )}
50 |
51 |
52 | );
53 | }
54 | );
55 |
56 | TimeInput.displayName = "TimeInput";
57 |
58 | // ================================== //
59 |
60 | export { TimeInput };
61 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
19 |
28 |
29 | ))
30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
31 |
32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
33 |
--------------------------------------------------------------------------------
/src/constants/cookies.const.ts:
--------------------------------------------------------------------------------
1 | export const THEME_COOKIE_NAME = "big-calendar-theme";
2 | export const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
3 |
4 | export const DEFAULT_VALUES = { theme: "dark" };
5 |
--------------------------------------------------------------------------------
/src/constants/theme.const.ts:
--------------------------------------------------------------------------------
1 | export const THEMES_VALUES = ["light", "dark"] as const;
2 |
--------------------------------------------------------------------------------
/src/cookies/get.ts:
--------------------------------------------------------------------------------
1 | import { cookies } from "next/headers";
2 |
3 | import { THEMES_VALUES } from "@/constants/theme.const";
4 | import { DEFAULT_VALUES } from "@/constants/cookies.const";
5 | import { THEME_COOKIE_NAME } from "@/constants/cookies.const";
6 |
7 | export type TTheme = (typeof THEMES_VALUES)[number];
8 |
9 | export function getTheme(): TTheme {
10 | const cookieStore = cookies();
11 | const theme = cookieStore.get(THEME_COOKIE_NAME)?.value;
12 | if (!THEMES_VALUES.includes(theme as TTheme)) return DEFAULT_VALUES.theme as TTheme;
13 | return theme as TTheme;
14 | }
15 |
--------------------------------------------------------------------------------
/src/cookies/set.ts:
--------------------------------------------------------------------------------
1 | import { THEME_COOKIE_NAME, THEME_COOKIE_MAX_AGE } from "@/constants/cookies.const";
2 |
3 | import type { TTheme } from "@/types";
4 |
5 | export function setTheme(theme: TTheme) {
6 | document.cookie = `${THEME_COOKIE_NAME}=${theme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}`;
7 | document.documentElement.classList.remove("light", "dark");
8 | document.documentElement.classList.add(theme);
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/use-disclosure.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | export function useDisclosure({ defaultIsOpen = false }: { defaultIsOpen?: boolean } = {}) {
4 | const [isOpen, setIsOpen] = useState(defaultIsOpen);
5 |
6 | const onOpen = () => setIsOpen(true);
7 | const onClose = () => setIsOpen(false);
8 | const onToggle = () => setIsOpen(currentValue => !currentValue);
9 |
10 | return { onOpen, onClose, isOpen, onToggle };
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/styles/fonts.ts:
--------------------------------------------------------------------------------
1 | import { Inter } from "next/font/google";
2 |
3 | export const inter = Inter({
4 | subsets: ["latin"],
5 | variable: "--font-inter",
6 | display: "swap",
7 | });
8 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind utilities;
3 | @tailwind components;
4 |
5 | html {
6 | font-family: var(--font-inter), system-ui, sans-serif;
7 | }
8 |
9 | body {
10 | overflow-x: hidden;
11 | -webkit-font-smoothing: antialiased;
12 | }
13 |
14 | @layer base {
15 | :root {
16 | --background: 0 0% 100%;
17 | --foreground: 240 10% 3.9%;
18 | --card: 0 0% 100%;
19 | --card-foreground: 240 10% 3.9%;
20 | --popover: 0 0% 100%;
21 | --popover-foreground: 240 10% 3.9%;
22 | --primary: 240 5.9% 10%;
23 | --primary-foreground: 0 0% 98%;
24 | --secondary: 240 4.8% 95.9%;
25 | --secondary-foreground: 240 5.9% 10%;
26 | --muted: 240 4.8% 95.9%;
27 | --muted-foreground: 240 3.8% 46.1%;
28 | --accent: 240 4.8% 95.9%;
29 | --accent-foreground: 240 5.9% 10%;
30 | --destructive: 0 84.2% 60.2%;
31 | --destructive-foreground: 0 0% 98%;
32 | --border: 240 5.9% 90%;
33 | --input: 240 5.9% 90%;
34 | --ring: 240 5.9% 10%;
35 | --radius: 0.5rem;
36 | --chart-1: 12 76% 61%;
37 | --chart-2: 173 58% 39%;
38 | --chart-3: 197 37% 24%;
39 | --chart-4: 43 74% 66%;
40 | --chart-5: 27 87% 67%;
41 | }
42 |
43 | .dark {
44 | --background: 240 10% 3.9%;
45 | --foreground: 0 0% 98%;
46 | --card: 240 10% 3.9%;
47 | --card-foreground: 0 0% 98%;
48 | --popover: 240 10% 3.9%;
49 | --popover-foreground: 0 0% 98%;
50 | --primary: 0 0% 98%;
51 | --primary-foreground: 240 5.9% 10%;
52 | --secondary: 240 3.7% 15.9%;
53 | --secondary-foreground: 0 0% 98%;
54 | --muted: 240 3.7% 15.9%;
55 | --muted-foreground: 240 5% 64.9%;
56 | --accent: 240 3.7% 15.9%;
57 | --accent-foreground: 0 0% 98%;
58 | --destructive: 0 62.8% 30.6%;
59 | --destructive-foreground: 0 0% 98%;
60 | --border: 240 3.7% 15.9%;
61 | --input: 240 3.7% 15.9%;
62 | --ring: 240 4.9% 83.9%;
63 | --chart-1: 220 70% 50%;
64 | --chart-2: 160 60% 45%;
65 | --chart-3: 30 80% 55%;
66 | --chart-4: 280 65% 60%;
67 | --chart-5: 340 75% 55%;
68 | }
69 | }
70 |
71 | @layer base {
72 | * {
73 | @apply border-border;
74 | }
75 | body {
76 | @apply bg-background text-foreground;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { THEMES_VALUES } from "@/constants/theme.const";
2 |
3 | export type TTheme = (typeof THEMES_VALUES)[number];
4 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import animatePlugin from "tailwindcss-animate";
2 |
3 | import type { Config } from "tailwindcss";
4 |
5 | const config: Config = {
6 | content: ["./src/**/*.tsx"],
7 | darkMode: "class",
8 | theme: {
9 | extend: {
10 | fontFamily: {
11 | inter: "var(--font-inter)",
12 | },
13 | fontSize: {
14 | xxs: ["0.625rem", "1rem"],
15 | },
16 | maxWidth: {
17 | "8xl": "90rem",
18 | },
19 | screens: {
20 | xs: "460px",
21 | sm: "576px",
22 | md: "768px",
23 | lg: "1024px",
24 | xl: "1280px",
25 | "2xl": "1440px",
26 | },
27 | spacing: {
28 | "18": "4.5rem",
29 | "4.5": "1.125rem",
30 | "5.5": "1.375rem",
31 | "6.5": "1.625rem",
32 | "8.5": "2.125rem",
33 | },
34 | borderRadius: {
35 | lg: "var(--radius)",
36 | md: "calc(var(--radius) - 2px)",
37 | sm: "calc(var(--radius) - 4px)",
38 | },
39 | backgroundImage: {
40 | "calendar-disabled-hour": "repeating-linear-gradient(-60deg, hsl(var(--border)) 0 0.5px, transparent 0.5px 8px)",
41 | },
42 | colors: {
43 | background: "hsl(var(--background))",
44 | foreground: "hsl(var(--foreground))",
45 | card: {
46 | DEFAULT: "hsl(var(--card))",
47 | foreground: "hsl(var(--card-foreground))",
48 | },
49 | popover: {
50 | DEFAULT: "hsl(var(--popover))",
51 | foreground: "hsl(var(--popover-foreground))",
52 | },
53 | primary: {
54 | DEFAULT: "hsl(var(--primary))",
55 | foreground: "hsl(var(--primary-foreground))",
56 | },
57 | secondary: {
58 | DEFAULT: "hsl(var(--secondary))",
59 | foreground: "hsl(var(--secondary-foreground))",
60 | },
61 | muted: {
62 | DEFAULT: "hsl(var(--muted))",
63 | foreground: "hsl(var(--muted-foreground))",
64 | },
65 | accent: {
66 | DEFAULT: "hsl(var(--accent))",
67 | foreground: "hsl(var(--accent-foreground))",
68 | },
69 | destructive: {
70 | DEFAULT: "hsl(var(--destructive))",
71 | foreground: "hsl(var(--destructive-foreground))",
72 | },
73 | border: "hsl(var(--border))",
74 | input: "hsl(var(--input))",
75 | ring: "hsl(var(--ring))",
76 | chart: {
77 | "1": "hsl(var(--chart-1))",
78 | "2": "hsl(var(--chart-2))",
79 | "3": "hsl(var(--chart-3))",
80 | "4": "hsl(var(--chart-4))",
81 | "5": "hsl(var(--chart-5))",
82 | },
83 | },
84 | keyframes: {
85 | "accordion-down": {
86 | from: {
87 | height: "0",
88 | },
89 | to: {
90 | height: "var(--radix-accordion-content-height)",
91 | },
92 | },
93 | "accordion-up": {
94 | from: {
95 | height: "var(--radix-accordion-content-height)",
96 | },
97 | to: {
98 | height: "0",
99 | },
100 | },
101 | },
102 | animation: {
103 | "accordion-down": "accordion-down 0.2s ease-out",
104 | "accordion-up": "accordion-up 0.2s ease-out",
105 | },
106 | },
107 | },
108 | plugins: [animatePlugin],
109 | } as const;
110 |
111 | export default config;
112 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------