Merge pull request #101 from wen-templari/fix/recurring-event-duplication

Fix recurring event duplication bug
This commit is contained in:
clas 2025-09-25 23:07:01 +08:00 committed by GitHub
commit 38e9a6af61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 208 additions and 45 deletions

View file

@ -28,7 +28,39 @@ const extractScheduleEventsInRange = (
rangeEnd: ICAL.Time,
): ScheduleEvent[] => {
const vevents = icalComp.getAllSubcomponents("vevent")
return vevents
// First, collect all exception events (events with RECURRENCE-ID)
const exceptions = new Map<string, Set<string>>() // uid -> set of recurrence-id strings
const exceptionEvents: ScheduleEvent[] = []
vevents.forEach((vevent) => {
const recurrenceId = vevent.getFirstPropertyValue("recurrence-id")
if (recurrenceId) {
const uid = vevent.getFirstPropertyValue("uid")
const event = new ICAL.Event(vevent)
// Check if exception is in range
if (event.startDate.compare(rangeEnd) <= 0 && event.endDate.compare(rangeStart) >= 0) {
exceptionEvents.push({
start: event.startDate.toJSDate(),
end: event.endDate.toJSDate(),
summary: event.summary,
description: event.description,
recurrenceId: recurrenceId.toString(),
})
}
// Track exception for filtering recurring events
if (!exceptions.has(uid)) {
exceptions.set(uid, new Set())
}
exceptions.get(uid)!.add(recurrenceId.toString())
}
})
// Process regular and recurring events
const regularEvents = vevents
.filter(vevent => !vevent.getFirstPropertyValue("recurrence-id"))
.flatMap<ScheduleEvent>((vevent) => {
const event = new ICAL.Event(vevent)
if (!event.isRecurring()) {
@ -41,8 +73,11 @@ const extractScheduleEventsInRange = (
recurrenceId: undefined,
}]
}
return expandEventOccurrences(event, rangeStart, rangeEnd)
const eventExceptions = exceptions.get(event.uid) || new Set()
return expandEventOccurrences(event, rangeStart, rangeEnd, eventExceptions)
})
return [...regularEvents, ...exceptionEvents]
.sort((a, b) => b.start.getTime() - a.start.getTime())
}
@ -50,6 +85,7 @@ const expandEventOccurrences = (
event: ICAL.Event,
rangeStart: ICAL.Time,
rangeEnd: ICAL.Time,
exceptions?: Set<string>,
): ScheduleEvent[] => {
const occurrences: ScheduleEvent[] = []
const iterator = event.iterator()
@ -58,6 +94,10 @@ const expandEventOccurrences = (
if (!next) break
if (next.compare(rangeEnd) > 0) break
if (next.compare(rangeStart) < 0) continue
// Skip this occurrence if it has an exception
if (exceptions && exceptions.has(next.toString())) continue
const details = event.getOccurrenceDetails(next)
occurrences.push({
start: details.startDate.toJSDate(),
@ -73,8 +113,40 @@ const expandEventOccurrences = (
const extractScheduleEvents = (icalComp: ICAL.Component): ScheduleEvent[] => {
const vevents = icalComp.getAllSubcomponents("vevent")
const rangeEnd = ICAL.Time.fromDateString("2026-01-01")
const rangeStart = ICAL.Time.fromDateString("2020-01-01")
return vevents
// First, collect all exception events (events with RECURRENCE-ID)
const exceptions = new Map<string, Set<string>>() // uid -> set of recurrence-id strings
const exceptionEvents: ScheduleEvent[] = []
vevents.forEach((vevent) => {
const recurrenceId = vevent.getFirstPropertyValue("recurrence-id")
if (recurrenceId) {
const uid = vevent.getFirstPropertyValue("uid")
const event = new ICAL.Event(vevent)
// Check if exception is in range
if (event.startDate.compare(rangeEnd) <= 0 && event.endDate.compare(rangeStart) >= 0) {
exceptionEvents.push({
start: event.startDate.toJSDate(),
end: event.endDate.toJSDate(),
summary: event.summary,
description: event.description,
recurrenceId: recurrenceId.toString(),
})
}
// Track exception for filtering recurring events
if (!exceptions.has(uid)) {
exceptions.set(uid, new Set())
}
exceptions.get(uid)!.add(recurrenceId.toString())
}
})
// Process regular and recurring events
const regularEvents = vevents
.filter(vevent => !vevent.getFirstPropertyValue("recurrence-id"))
.flatMap<ScheduleEvent>((vevent) => {
const event = new ICAL.Event(vevent)
if (event.iterator().complete) {
@ -84,11 +156,13 @@ const extractScheduleEvents = (icalComp: ICAL.Component): ScheduleEvent[] => {
summary: event.summary,
description: event.description,
recurrenceId: event.uid || event.startDate.toString(),
}]
}
return expandEventOccurrences(event, rangeEnd)
const eventExceptions = exceptions.get(event.uid) || new Set()
return expandEventOccurrences(event, rangeStart, rangeEnd, eventExceptions)
})
return [...regularEvents, ...exceptionEvents]
.sort((a, b) => b.start.getTime() - a.start.getTime())
}

View file

@ -140,15 +140,15 @@ function TicketDetailDrawer(props: {
return (
<Drawer isOpen={isOpen} onOpenChange={onOpenChange}>
<DrawerContent>
<DrawerHeader>
<h2 className="text-2xl font-bold"></h2>
{isLoading}
<DrawerHeader className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
<h2 className="text-xl sm:text-2xl font-bold"></h2>
{isLoading && <span className="text-sm text-gray-500">{isLoading}</span>}
</DrawerHeader>
<DrawerBody>
<DrawerBody className="px-4 sm:px-6">
<EventDetail ref={eventDetailRef} eventId={props.event?.eventId}>
{
event => (
<div className="mb-12 flex flex-col gap-2">
<div className="mb-8 sm:mb-12 flex flex-col gap-3 sm:gap-2">
{
availableActions?.map((action) => {
return (
@ -171,8 +171,8 @@ function TicketDetailDrawer(props: {
}
</EventDetail>
</DrawerBody>
<DrawerFooter>
<Button variant="flat" onPress={onClose}>
<DrawerFooter className="px-4 sm:px-6">
<Button variant="flat" onPress={onClose} className="w-full sm:w-auto">
</Button>
</DrawerFooter>
@ -336,6 +336,52 @@ export default function App() {
onOpen()
}
const MobileEventCard = ({ event }: { event: PublicEvent }) => (
<button className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm" onClick={() => onOpenEventDetail(event)}>
<div className="mb-3 flex gap-2 items-center justify-between">
<div className="text font-medium text-gray-900 line-clamp-2">
{event.problem}
<span className="text font-medium text-gray-400 ml-1">#{event.eventId}</span>
</div>
<div className="">
<EventStatusChip status={event.status} size="sm" />
</div>
</div>
<div className="h-18">
<div className="flex items-center gap-2 text-sm text-gray-600">
<div className="">
{ dayjs(event.gmtCreate).format("YYYY-MM-DD HH:mm") }
</div>
{event.model && (
<div>
{event.model}
</div>
)}
<div>
{ event.size && <Chip size="sm">size:{event.size}</Chip>}
</div>
{event.member && (
<div className="flex items-center gap-2 ">
<User
avatarProps={{ radius: "full", src: event.member.avatar, size: "sm" }}
name=""
classNames={{
base: "justify-start",
name: "text-sm",
description: "text-xs",
}}
/>
</div>
)}
</div>
</div>
</button>
)
const renderCell = useCallback((event: PublicEvent, columnKey: string | number) => {
const cellValue = event[columnKey]
@ -389,45 +435,89 @@ export default function App() {
}
}, [])
return (
<section className="box-border max-w-[1024px] mx-auto px-[22px] mb-24">
<div className="mt-6 flex justify-between items-center">
<div className="text-2xl font-bold"></div>
<section className="box-border max-w-full px-4 sm:px-6 lg:max-w-[1024px] lg:px-[22px] mx-auto mb-16 sm:mb-24">
<div className="mt-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="text-xl sm:text-2xl font-bold"></div>
{
userInfo?.roles?.find(v => v.toLowerCase() == "repair admin")
? <ExportExcelModal></ExportExcelModal>
? <div className="w-full sm:w-auto"><ExportExcelModal></ExportExcelModal></div>
: <></>
}
</div>
<div className="my-8 flex flex-col gap-4">
<Table
aria-label="Example table with dynamic content"
sortDescriptor={list.sortDescriptor}
onSortChange={list.sort}
bottomContent={(
<div className="flex w-full justify-center">
<Pagination
isCompact
showControls
showShadow
color="secondary"
page={page}
total={pages}
onChange={page => setPage(page)}
/>
</div>
)}
>
<TableHeader columns={columns}>
{column => <TableColumn key={column.key} allowsSorting={column.allowSorting} children={column.content ?? <div>{column.label}</div>}></TableColumn>}
</TableHeader>
<TableBody isLoading={isLoading} items={items} loadingContent={<Spinner label="Loading..." />}>
{item => (
<TableRow key={item.eventId}>
{columnKey => <TableCell>{renderCell(item, columnKey)}</TableCell>}
</TableRow>
{/* Mobile Cards Layout */}
<div className="block sm:hidden">
{/* Filter Section for Mobile */}
<div className="mb-4 flex items-center gap-2">
<span className="text-sm font-medium">:</span>
<CheckboxPopover value={statusFilter} onValueChange={setStatusFilter} />
</div>
{isLoading
? (
<div className="flex justify-center py-8">
<Spinner label="Loading..." />
</div>
)
: (
<div className="flex flex-col gap-4">
{items.map(event => (
<MobileEventCard key={event.eventId} event={event} />
))}
{items.length === 0 && (
<div className="text-center py-8 text-gray-500">
</div>
)}
</div>
)}
{/* Mobile Pagination */}
<div className="mt-6 flex justify-center">
<Pagination
isCompact
showControls
showShadow
color="secondary"
page={page}
total={pages}
onChange={page => setPage(page)}
/>
</div>
</div>
{/* Desktop Table Layout */}
<div className="hidden sm:block">
<Table
aria-label="Example table with dynamic content"
sortDescriptor={list.sortDescriptor}
onSortChange={list.sort}
bottomContent={(
<div className="flex w-full justify-center">
<Pagination
isCompact
showControls
showShadow
color="secondary"
page={page}
total={pages}
onChange={page => setPage(page)}
/>
</div>
)}
</TableBody>
</Table>
>
<TableHeader columns={columns}>
{column => <TableColumn key={column.key} allowsSorting={column.allowSorting} children={column.content ?? <div>{column.label}</div>}></TableColumn>}
</TableHeader>
<TableBody isLoading={isLoading} items={items} loadingContent={<Spinner label="Loading..." />}>
{item => (
<TableRow key={item.eventId}>
{columnKey => <TableCell>{renderCell(item, columnKey)}</TableCell>}
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<TicketDetailDrawer
event={activeEvent}

View file

@ -122,7 +122,6 @@ progress {
button {
background: none;
border: 0;
box-sizing: border-box;
color: inherit;
cursor: pointer;