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
This commit is contained in:
@@ -60,7 +60,7 @@ Rules:
|
|||||||
const content = data.choices[0].message.content;
|
const content = data.choices[0].message.content;
|
||||||
const parsed = JSON.parse(content);
|
const parsed = JSON.parse(content);
|
||||||
return NextResponse.json(parsed);
|
return NextResponse.json(parsed);
|
||||||
} catch (e) {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to parse AI output", raw: data },
|
{ error: "Failed to parse AI output", raw: data },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { CalendarEvent } from "@/lib/types";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/card'
|
import { Card, CardHeader, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { LucideMapPin, Clock, MoreHorizontal } from 'lucide-react'
|
import { LucideMapPin, Clock, MoreHorizontal } from 'lucide-react'
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||||
|
import { RRuleDisplay } from '@/components/rrule-display'
|
||||||
import type { CalendarEvent } from '@/lib/types'
|
import type { CalendarEvent } from '@/lib/types'
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
@@ -40,9 +40,9 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
|||||||
{event.title}
|
{event.title}
|
||||||
</h3>
|
</h3>
|
||||||
{event.recurrenceRule && (
|
{event.recurrenceRule && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<div className="mt-1">
|
||||||
Repeats: {event.recurrenceRule}
|
<RRuleDisplay rrule={event.recurrenceRule} />
|
||||||
</Badge>
|
</div>
|
||||||
)}
|
)}
|
||||||
{event.description && (
|
{event.description && (
|
||||||
<p className="text-sm text-muted-foreground mt-2 break-words">
|
<p className="text-sm text-muted-foreground mt-2 break-words">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
|
|
||||||
type Recurrence = {
|
type Recurrence = {
|
||||||
freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY"
|
freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY"
|
||||||
|
|||||||
244
src/components/rrule-display.tsx
Normal file
244
src/components/rrule-display.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={className}>
|
||||||
|
<span className="text-sm text-muted-foreground">{humanText}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">{humanText}</div>
|
||||||
|
|
||||||
|
{showBadges && details.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{details.map((detail, index) => (
|
||||||
|
<Badge key={index} variant="outline" className="text-xs">
|
||||||
|
{detail}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user