feat(calendar): enhance calendar error handlick and loading

This commit is contained in:
Dominik 2025-06-24 10:26:04 +02:00
parent fd7be58541
commit 758afb36b9
5 changed files with 155 additions and 38 deletions

View file

@ -61,11 +61,13 @@
"react-datepicker": "^8.4.0", "react-datepicker": "^8.4.0",
"react-day-picker": "^9.7.0", "react-day-picker": "^9.7.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.4", "react-hook-form": "^7.56.4",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"swagger-ui-react": "^5.24.1", "swagger-ui-react": "^5.24.1",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"zod": "^3.25.60" "zod": "^3.25.60",
"zod-validation-error": "^3.5.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "3.3.1", "@eslint/eslintrc": "3.3.1",

View file

@ -19,18 +19,22 @@ export const BlockedSlotSchema = zod
description: 'Blocked time slot in the user calendar', description: 'Blocked time slot in the user calendar',
}); });
export const OwnedBlockedSlotSchema = BlockedSlotSchema.extend({ export const OwnedBlockedSlotSchema = zod
id: zod.string(), .object({
reason: zod.string().nullish(), start_time: eventStartTimeSchema,
is_recurring: zod.boolean().default(false), end_time: eventEndTimeSchema,
recurrence_end_date: zod.date().nullish(), id: zod.string(),
rrule: zod.string().nullish(), reason: zod.string().nullish(),
created_at: zod.date().nullish(), is_recurring: zod.boolean().default(false),
updated_at: zod.date().nullish(), recurrence_end_date: zod.date().nullish(),
type: zod.literal('blocked_owned'), rrule: zod.string().nullish(),
}).openapi('OwnedBlockedSlotSchema', { created_at: zod.date().nullish(),
description: 'Blocked slot owned by the user', updated_at: zod.date().nullish(),
}); type: zod.literal('blocked_owned'),
})
.openapi('OwnedBlockedSlotSchema', {
description: 'Blocked slot owned by the user',
});
export const VisibleSlotSchema = EventSchema.omit({ export const VisibleSlotSchema = EventSchema.omit({
organizer: true, organizer: true,

View file

@ -11,6 +11,12 @@ import { useGetApiUserUserCalendar } from '@/generated/api/user/user';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { usePatchApiEventEventID } from '@/generated/api/event/event'; import { usePatchApiEventEventID } from '@/generated/api/event/event';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { UserCalendarSchemaItem } from '@/generated/api/meetup.schemas';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { Button } from '@/components/ui/button';
import { fromZodIssue } from 'zod-validation-error/v4';
import type { $ZodIssue } from 'zod/v4/core';
moment.updateLocale('en', { moment.updateLocale('en', {
week: { week: {
@ -24,16 +30,49 @@ const DaDRBCalendar = withDragAndDrop<
id: string; id: string;
start: Date; start: Date;
end: Date; end: Date;
type: UserCalendarSchemaItem['type'];
}, },
{ {
id: string; id: string;
title: string; title: string;
type: UserCalendarSchemaItem['type'];
} }
>(RBCalendar); >(RBCalendar);
const localizer = momentLocalizer(moment); const localizer = momentLocalizer(moment);
export default function Calendar({ userId }: { userId: string }) { export default function Calendar({ userId }: { userId?: string }) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => (
<div className='flex flex-col items-center justify-center h-full'>
There was an error!
<p className='text-red-500'>
{typeof error === 'string'
? error
: error.errors
.map((e: $ZodIssue) => fromZodIssue(e).toString())
.join(', ')}
</p>
<Button onClick={() => resetErrorBoundary()}>Try again</Button>
</div>
)}
>
{userId ? (
<CalendarWithUserEvents userId={userId} />
) : (
<CalendarWithoutUserEvents />
)}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
function CalendarWithUserEvents({ userId }: { userId: string }) {
const sesstion = useSession(); const sesstion = useSession();
const [currentView, setCurrentView] = React.useState< const [currentView, setCurrentView] = React.useState<
'month' | 'week' | 'day' | 'agenda' | 'work_week' 'month' | 'week' | 'day' | 'agenda' | 'work_week'
@ -41,33 +80,52 @@ export default function Calendar({ userId }: { userId: string }) {
const [currentDate, setCurrentDate] = React.useState<Date>(new Date()); const [currentDate, setCurrentDate] = React.useState<Date>(new Date());
const router = useRouter(); const router = useRouter();
const { data, refetch } = useGetApiUserUserCalendar(userId, { const { data, refetch, error, isError } = useGetApiUserUserCalendar(
start: moment(currentDate) userId,
.startOf( {
currentView === 'agenda' start: moment(currentDate)
? 'month' .startOf(
: currentView === 'work_week' currentView === 'agenda'
? 'week' ? 'month'
: currentView, : currentView === 'work_week'
) ? 'week'
.toISOString(), : currentView,
end: moment(currentDate) )
.endOf( .toISOString(),
currentView === 'agenda' end: moment(currentDate)
? 'month' .endOf(
: currentView === 'work_week' currentView === 'agenda'
? 'week' ? 'month'
: currentView, : currentView === 'work_week'
) ? 'week'
.toISOString(), : currentView,
}); )
.toISOString(),
},
{
query: {
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchOnMount: true,
},
},
);
const { mutate: patchEvent } = usePatchApiEventEventID(); if (isError) {
throw error.response?.data || 'Failed to fetch calendar data';
}
const { mutate: patchEvent } = usePatchApiEventEventID({
mutation: {
throwOnError(error) {
throw error.response?.data || 'Failed to update event';
},
},
});
return ( return (
<DaDRBCalendar <DaDRBCalendar
localizer={localizer} localizer={localizer}
style={{ height: 500 }}
culture='de-DE' culture='de-DE'
defaultView='week' defaultView='week'
components={{ components={{
@ -85,6 +143,7 @@ export default function Calendar({ userId }: { userId: string }) {
title: event.type === 'event' ? event.title : 'Blocker', title: event.type === 'event' ? event.title : 'Blocker',
start: new Date(event.start_time), start: new Date(event.start_time),
end: new Date(event.end_time), end: new Date(event.end_time),
type: event.type,
})) ?? [] })) ?? []
} }
onSelectEvent={(event) => { onSelectEvent={(event) => {
@ -102,6 +161,7 @@ export default function Calendar({ userId }: { userId: string }) {
selectable={sesstion.data?.user?.id === userId} selectable={sesstion.data?.user?.id === userId}
onEventDrop={(event) => { onEventDrop={(event) => {
const { start, end, event: droppedEvent } = event; const { start, end, event: droppedEvent } = event;
if (droppedEvent.type === 'blocked_private') return;
const startISO = new Date(start).toISOString(); const startISO = new Date(start).toISOString();
const endISO = new Date(end).toISOString(); const endISO = new Date(end).toISOString();
patchEvent( patchEvent(
@ -124,8 +184,13 @@ export default function Calendar({ userId }: { userId: string }) {
}} }}
onEventResize={(event) => { onEventResize={(event) => {
const { start, end, event: resizedEvent } = event; const { start, end, event: resizedEvent } = event;
if (resizedEvent.type === 'blocked_private') return;
const startISO = new Date(start).toISOString(); const startISO = new Date(start).toISOString();
const endISO = new Date(end).toISOString(); const endISO = new Date(end).toISOString();
if (startISO === endISO) {
console.warn('Start and end times are the same, skipping resize.');
return;
}
patchEvent( patchEvent(
{ {
eventID: resizedEvent.id, eventID: resizedEvent.id,
@ -147,3 +212,27 @@ export default function Calendar({ userId }: { userId: string }) {
/> />
); );
} }
function CalendarWithoutUserEvents() {
const [currentView, setCurrentView] = React.useState<
'month' | 'week' | 'day' | 'agenda' | 'work_week'
>('week');
const [currentDate, setCurrentDate] = React.useState<Date>(new Date());
return (
<DaDRBCalendar
localizer={localizer}
culture='de-DE'
defaultView='week'
components={{
toolbar: CustomToolbar,
}}
onView={setCurrentView}
view={currentView}
date={currentDate}
onNavigate={(date) => {
setCurrentDate(date);
}}
/>
);
}

View file

@ -109,5 +109,5 @@
} }
.datepicker-box { .datepicker-box {
z-index: 9999; z-index: 5;
} }

View file

@ -156,7 +156,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.13.8, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.6.3, @babel/runtime@npm:^7.8.7": "@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.8, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.6.3, @babel/runtime@npm:^7.8.7":
version: 7.27.6 version: 7.27.6
resolution: "@babel/runtime@npm:7.27.6" resolution: "@babel/runtime@npm:7.27.6"
checksum: 10c0/89726be83f356f511dcdb74d3ea4d873a5f0cf0017d4530cb53aa27380c01ca102d573eff8b8b77815e624b1f8c24e7f0311834ad4fb632c90a770fda00bd4c8 checksum: 10c0/89726be83f356f511dcdb74d3ea4d873a5f0cf0017d4530cb53aa27380c01ca102d573eff8b8b77815e624b1f8c24e7f0311834ad4fb632c90a770fda00bd4c8
@ -7730,6 +7730,7 @@ __metadata:
react-datepicker: "npm:^8.4.0" react-datepicker: "npm:^8.4.0"
react-day-picker: "npm:^9.7.0" react-day-picker: "npm:^9.7.0"
react-dom: "npm:^19.0.0" react-dom: "npm:^19.0.0"
react-error-boundary: "npm:^6.0.0"
react-hook-form: "npm:^7.56.4" react-hook-form: "npm:^7.56.4"
sonner: "npm:^2.0.5" sonner: "npm:^2.0.5"
swagger-ui-react: "npm:^5.24.1" swagger-ui-react: "npm:^5.24.1"
@ -7740,6 +7741,7 @@ __metadata:
tw-animate-css: "npm:1.3.4" tw-animate-css: "npm:1.3.4"
typescript: "npm:^5.8.3" typescript: "npm:^5.8.3"
zod: "npm:^3.25.60" zod: "npm:^3.25.60"
zod-validation-error: "npm:^3.5.2"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -9004,6 +9006,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-error-boundary@npm:^6.0.0":
version: 6.0.0
resolution: "react-error-boundary@npm:6.0.0"
dependencies:
"@babel/runtime": "npm:^7.12.5"
peerDependencies:
react: ">=16.13.1"
checksum: 10c0/1914d600dee95a14f14af4afe9867b0d35c26c4f7826d23208800ba2a99728659029aad60a6ef95e13430b4d79c2c4c9b3585f50bf508450478760d2e4e732d8
languageName: node
linkType: hard
"react-hook-form@npm:^7.56.4": "react-hook-form@npm:^7.56.4":
version: 7.58.1 version: 7.58.1
resolution: "react-hook-form@npm:7.58.1" resolution: "react-hook-form@npm:7.58.1"
@ -11274,6 +11287,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"zod-validation-error@npm:^3.5.2":
version: 3.5.2
resolution: "zod-validation-error@npm:3.5.2"
peerDependencies:
zod: ^3.25.0
checksum: 10c0/da50926ec91c7ad2880bacc5010a53c42de58f73f7c4629baad8132695c4daf74dd68620787198da81aca85134471182ed4c566b5fc9bc5349aefd8540946d57
languageName: node
linkType: hard
"zod@npm:^3.25.60": "zod@npm:^3.25.60":
version: 3.25.67 version: 3.25.67
resolution: "zod@npm:3.25.67" resolution: "zod@npm:3.25.67"