From 824768ce93bacca2a27dcfb18ee50fabf74897d6 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Fri, 22 Aug 2025 13:35:13 -0400 Subject: [PATCH] Add RRuleDisplay component and clean up unused imports - Create new RRuleDisplay component for better recurrence rule formatting - Replace Badge with RRuleDisplay in EventCard for improved UX - Remove unused imports across multiple files (CalendarEvent, Badge, Card components) - Remove unused catch parameter in ai-event route --- src/app/api/ai-event/route.ts | 2 +- src/app/api/ai-summary/route.ts | 1 - src/components/event-card.tsx | 8 +- src/components/recurrence-picker.tsx | 1 - src/components/rrule-display.tsx | 244 +++++++++++++++++++++++++++ 5 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 src/components/rrule-display.tsx diff --git a/src/app/api/ai-event/route.ts b/src/app/api/ai-event/route.ts index b355a95..715d29b 100644 --- a/src/app/api/ai-event/route.ts +++ b/src/app/api/ai-event/route.ts @@ -60,7 +60,7 @@ Rules: const content = data.choices[0].message.content; const parsed = JSON.parse(content); return NextResponse.json(parsed); - } catch (e) { + } catch { return NextResponse.json( { error: "Failed to parse AI output", raw: data }, { status: 500 }, diff --git a/src/app/api/ai-summary/route.ts b/src/app/api/ai-summary/route.ts index 2ae4ff5..dedfd31 100644 --- a/src/app/api/ai-summary/route.ts +++ b/src/app/api/ai-summary/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from "next/server"; -import type { CalendarEvent } from "@/lib/types"; export async function POST(request: Request) { try { diff --git a/src/components/event-card.tsx b/src/components/event-card.tsx index 1a1a81e..fadf846 100644 --- a/src/components/event-card.tsx +++ b/src/components/event-card.tsx @@ -1,8 +1,8 @@ import { Button } from '@/components/ui/button' import { Card, CardHeader, CardContent } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' import { LucideMapPin, Clock, MoreHorizontal } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { RRuleDisplay } from '@/components/rrule-display' import type { CalendarEvent } from '@/lib/types' interface EventCardProps { @@ -40,9 +40,9 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => { {event.title} {event.recurrenceRule && ( - - Repeats: {event.recurrenceRule} - +
+ +
)} {event.description && (

diff --git a/src/components/recurrence-picker.tsx b/src/components/recurrence-picker.tsx index edab6a2..58f371f 100644 --- a/src/components/recurrence-picker.tsx +++ b/src/components/recurrence-picker.tsx @@ -5,7 +5,6 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Checkbox } from "@/components/ui/checkbox" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" type Recurrence = { freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY" diff --git a/src/components/rrule-display.tsx b/src/components/rrule-display.tsx new file mode 100644 index 0000000..d491afa --- /dev/null +++ b/src/components/rrule-display.tsx @@ -0,0 +1,244 @@ +import { Badge } from "@/components/ui/badge" +import type { RecurrenceRule } from "@/lib/rfc5545-types" + +interface RRuleDisplayProps { + rrule: string | RecurrenceRule + className?: string +} + +export function RRuleDisplay({ rrule, className }: RRuleDisplayProps) { + const parsedRule = typeof rrule === 'string' ? parseRRuleString(rrule) : rrule + const humanText = formatRRuleToHuman(parsedRule) + + return ( +

+ {humanText} +
+ ) +} + +interface RRuleDisplayDetailedProps { + rrule: string | RecurrenceRule + className?: string + showBadges?: boolean +} + +export function RRuleDisplayDetailed({ rrule, className, showBadges = true }: RRuleDisplayDetailedProps) { + const parsedRule = typeof rrule === 'string' ? parseRRuleString(rrule) : rrule + const humanText = formatRRuleToHuman(parsedRule) + const details = getRRuleDetails(parsedRule) + + return ( +
+
+
{humanText}
+ + {showBadges && details.length > 0 && ( +
+ {details.map((detail, index) => ( + + {detail} + + ))} +
+ )} +
+
+ ) +} + +function parseRRuleString(rruleString: string): RecurrenceRule { + const parts = Object.fromEntries(rruleString.split(";").map(p => p.split("="))) + + return { + freq: parts.FREQ as RecurrenceRule['freq'], + until: parts.UNTIL ? new Date(parts.UNTIL.replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/, '$1-$2-$3T$4:$5:$6Z')).toISOString() : undefined, + count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined, + interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : undefined, + bySecond: parts.BYSECOND ? parts.BYSECOND.split(",").map((n: string) => parseInt(n, 10)) : undefined, + byMinute: parts.BYMINUTE ? parts.BYMINUTE.split(",").map((n: string) => parseInt(n, 10)) : undefined, + byHour: parts.BYHOUR ? parts.BYHOUR.split(",").map((n: string) => parseInt(n, 10)) : undefined, + byDay: parts.BYDAY ? parts.BYDAY.split(",") : undefined, + byMonthDay: parts.BYMONTHDAY ? parts.BYMONTHDAY.split(",").map((n: string) => parseInt(n, 10)) : undefined, + byYearDay: parts.BYYEARDAY ? parts.BYYEARDAY.split(",").map((n: string) => parseInt(n, 10)) : undefined, + byWeekNo: parts.BYWEEKNO ? parts.BYWEEKNO.split(",").map((n: string) => parseInt(n, 10)) : undefined, + byMonth: parts.BYMONTH ? parts.BYMONTH.split(",").map((n: string) => parseInt(n, 10)) : undefined, + bySetPos: parts.BYSETPOS ? parts.BYSETPOS.split(",").map((n: string) => parseInt(n, 10)) : undefined, + wkst: parts.WKST as RecurrenceRule['wkst'], + } +} + +function formatRRuleToHuman(rule: RecurrenceRule): string { + const { freq, interval = 1, count, until, byDay, byMonthDay, byMonth, byHour, byMinute, bySecond } = rule + + let text = "" + + // Base frequency + switch (freq) { + case 'SECONDLY': + text = interval === 1 ? "Every second" : `Every ${interval} seconds` + break + case 'MINUTELY': + text = interval === 1 ? "Every minute" : `Every ${interval} minutes` + break + case 'HOURLY': + text = interval === 1 ? "Every hour" : `Every ${interval} hours` + break + case 'DAILY': + text = interval === 1 ? "Daily" : `Every ${interval} days` + break + case 'WEEKLY': + text = interval === 1 ? "Weekly" : `Every ${interval} weeks` + break + case 'MONTHLY': + text = interval === 1 ? "Monthly" : `Every ${interval} months` + break + case 'YEARLY': + text = interval === 1 ? "Yearly" : `Every ${interval} years` + break + } + + // Add day specifications + if (byDay?.length) { + const dayNames = { + 'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday', 'WE': 'Wednesday', + 'TH': 'Thursday', 'FR': 'Friday', 'SA': 'Saturday' + } + + const days = byDay.map(day => { + // Handle numbered days like "2TU" (second Tuesday) + const match = day.match(/^(-?\d+)?([A-Z]{2})$/) + if (match) { + const [, num, dayCode] = match + const dayName = dayNames[dayCode as keyof typeof dayNames] + if (num) { + const ordinal = getOrdinal(parseInt(num)) + return `${ordinal} ${dayName}` + } + return dayName + } + return day + }) + + if (freq === 'WEEKLY') { + text += ` on ${formatList(days)}` + } else { + text += ` on ${formatList(days)}` + } + } + + // Add month day specifications + if (byMonthDay?.length) { + const days = byMonthDay.map(day => { + if (day < 0) { + return `${getOrdinal(Math.abs(day))} to last day` + } + return getOrdinal(day) + }) + text += ` on the ${formatList(days)}` + } + + // Add month specifications + if (byMonth?.length) { + const monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ] + const months = byMonth.map(month => monthNames[month - 1]) + text += ` in ${formatList(months)}` + } + + // Add time specifications + if (byHour?.length || byMinute?.length || bySecond?.length) { + const timeSpecs = [] + if (byHour?.length) { + const hours = byHour.map(h => `${h.toString().padStart(2, '0')}:00`) + timeSpecs.push(`at ${formatList(hours)}`) + } + if (byMinute?.length && !byHour?.length) { + timeSpecs.push(`at minute ${formatList(byMinute.map(String))}`) + } + if (bySecond?.length && !byHour?.length && !byMinute?.length) { + timeSpecs.push(`at second ${formatList(bySecond.map(String))}`) + } + if (timeSpecs.length) { + text += ` ${timeSpecs.join(' ')}` + } + } + + // Add end conditions + if (count) { + text += `, ${count} time${count === 1 ? '' : 's'}` + } else if (until) { + const date = new Date(until) + text += `, until ${date.toLocaleDateString()}` + } + + return text +} + +function getRRuleDetails(rule: RecurrenceRule): string[] { + const details: string[] = [] + + if (rule.wkst && rule.wkst !== 'MO') { + const dayNames = { + 'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday', 'WE': 'Wednesday', + 'TH': 'Thursday', 'FR': 'Friday', 'SA': 'Saturday' + } + details.push(`Week starts ${dayNames[rule.wkst]}`) + } + + if (rule.byWeekNo?.length) { + details.push(`Week ${formatList(rule.byWeekNo.map(String))}`) + } + + if (rule.byYearDay?.length) { + details.push(`Day ${formatList(rule.byYearDay.map(String))} of year`) + } + + if (rule.bySetPos?.length) { + const positions = rule.bySetPos.map(pos => { + if (pos < 0) { + return `${getOrdinal(Math.abs(pos))} to last` + } + return getOrdinal(pos) + }) + details.push(`Position ${formatList(positions)}`) + } + + return details +} + +function getOrdinal(num: number): string { + const suffix = ['th', 'st', 'nd', 'rd'] + const v = num % 100 + return num + (suffix[(v - 20) % 10] || suffix[v] || suffix[0]) +} + +function formatList(items: string[]): string { + if (items.length === 0) return '' + if (items.length === 1) return items[0] + if (items.length === 2) return `${items[0]} and ${items[1]}` + return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}` +} + +// Hook for easy usage in components +export function useRRuleDisplay(rrule?: string) { + if (!rrule) return null + + try { + const parsedRule = parseRRuleString(rrule) + return { + humanText: formatRRuleToHuman(parsedRule), + details: getRRuleDetails(parsedRule), + parsedRule + } + } catch (error) { + return { + humanText: "Invalid recurrence rule", + details: [], + parsedRule: null, + error: error instanceof Error ? error.message : String(error) + } + } +}