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)
+ }
+ }
+}