add recurrence editor component

This commit is contained in:
2025-08-15 22:46:44 -04:00
parent 2d5db29f27
commit 836feb2e11
3 changed files with 149 additions and 18 deletions

View File

@@ -4,26 +4,30 @@ export async function POST(request: Request) {
const { prompt } = await request.json(); const { prompt } = await request.json();
const systemPrompt = ` const systemPrompt = `
You are an assistant that converts natural language requests into an ARRAY of JSON calendar events. You are an assistant that converts natural language into an ARRAY of calendar events.
TypeScript interface: TypeScript type:
{ {
id?: string, id?: string,
title: string, title: string,
description?: string, description?: string,
location?: string, location?: string,
url?: string, url?: string,
start: string, // ISO datetime like 2024-06-14T13:00:00Z start: string, // ISO datetime
end?: string, end?: string,
allDay?: boolean allDay?: boolean,
recurrenceRule?: string // valid iCal RRULE string like FREQ=WEEKLY;BYDAY=MO;INTERVAL=1
}[] }[]
Rules: Rules:
- If the user describes multiple events in one prompt, return multiple objects (one per event). - If the user describes multiple events in one prompt, return multiple objects (one per event).
- Always return a valid JSON array of objects, even if there's only one event. - Always return a valid JSON array of objects, even if there's only one event.
- Today is ${new Date().toLocaleString()}. - Today is ${new Date().toLocaleString()}.
- If no time is given, assume allDay event. - If no time is given, assume allDay event.
- If no end time is given (and event is not allDay), default to 1 hour after start. - If no end time is given (and event is not allDay), default to 1 hour after start.
Output ONLY valid JSON (no prose). - If multiple events are described, return multiple.
- If recurrence is implied (e.g. "every Monday", "daily for 10 days", "monthly on the 15th"), generate a recurrenceRule.
- Output ONLY valid JSON (no prose).
`; `;
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { RecurrencePicker } from '@/components/recurrence-picker'
import { addEvent, deleteEvent, getAllEvents, clearEvents, getDB } from '@/lib/db' import { addEvent, deleteEvent, getAllEvents, clearEvents, getDB } from '@/lib/db'
import { parseICS, generateICS } from '@/lib/ical' import { parseICS, generateICS } from '@/lib/ical'
@@ -25,7 +26,7 @@ export default function HomePage() {
const [start, setStart] = useState('') const [start, setStart] = useState('')
const [end, setEnd] = useState('') const [end, setEnd] = useState('')
const [allDay, setAllDay] = useState(false) const [allDay, setAllDay] = useState(false)
const [recurrenceRule, setRecurrenceRule] = useState('') const [recurrenceRule, setRecurrenceRule] = useState<string | undefined>(undefined)
// AI // AI
const [aiPrompt, setAiPrompt] = useState('') const [aiPrompt, setAiPrompt] = useState('')
@@ -58,7 +59,7 @@ export default function HomePage() {
description, description,
location, location,
url, url,
recurrenceRule: recurrenceRule || undefined, recurrenceRule,
start, start,
end: end || undefined, end: end || undefined,
allDay, allDay,
@@ -155,7 +156,7 @@ export default function HomePage() {
setAllDay(ev.allDay || false) setAllDay(ev.allDay || false)
setEditingId(null) setEditingId(null)
setDialogOpen(true) setDialogOpen(true)
setRecurrenceRule(ev.recurrenceRule || '') setRecurrenceRule(ev.recurrenceRule || undefined)
} else { } else {
// Save them all directly to DB // Save them all directly to DB
for (const ev of data) { for (const ev of data) {
@@ -308,11 +309,8 @@ export default function HomePage() {
value={description} onChange={e => setDescription(e.target.value)} /> value={description} onChange={e => setDescription(e.target.value)} />
<Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} /> <Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} />
<Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} /> <Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} />
<Input <RecurrencePicker value={recurrenceRule} onChange={setRecurrenceRule} />
placeholder="Recurrence rule (e.g. FREQ=WEEKLY;BYDAY=MO)"
value={recurrenceRule}
onChange={e => setRecurrenceRule(e.target.value)}
/>
<label className="flex items-center gap-2 mt-2"> <label className="flex items-center gap-2 mt-2">
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} /> <input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} />
All day event All day event

View File

@@ -0,0 +1,129 @@
'use client'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
type Recurrence = {
freq: 'NONE' | 'DAILY' | 'WEEKLY' | 'MONTHLY'
interval: number
byDay?: string[]
count?: number
until?: string
}
interface Props {
value?: string
onChange: (rrule: string | undefined) => void
}
export function RecurrencePicker({ value, onChange }: Props) {
const [rec, setRec] = useState<Recurrence>(() => {
// If existing rrule, parse minimally (for simplicity we only rehydrate FREQ and INTERVAL)
if (value) {
const parts = Object.fromEntries(
value.split(';').map(p => p.split('='))
)
return {
freq: (parts.FREQ as any) || 'NONE',
interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : 1,
byDay: parts.BYDAY ? parts.BYDAY.split(',') : [],
count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined,
until: parts.UNTIL
}
}
return { freq: 'NONE', interval: 1 }
})
const update = (updates: Partial<Recurrence>) => {
const newRec = { ...rec, ...updates }
setRec(newRec)
if (newRec.freq === 'NONE') {
onChange(undefined)
return
}
// Build RRULE string
let rrule = `FREQ=${newRec.freq};INTERVAL=${newRec.interval}`
if (newRec.freq === 'WEEKLY' && newRec.byDay?.length) {
rrule += `;BYDAY=${newRec.byDay.join(',')}`
}
if (newRec.count) rrule += `;COUNT=${newRec.count}`
if (newRec.until) rrule += `;UNTIL=${newRec.until.replace(/-/g, '')}T000000Z`
onChange(rrule)
}
const toggleDay = (day: string) => {
const byDay = rec.byDay || []
const newByDay = byDay.includes(day)
? byDay.filter(d => d !== day)
: [...byDay, day]
update({ byDay: newByDay })
}
return (
<div className="space-y-2 border rounded p-2 mt-2 bg-gray-50">
<label className="block font-semibold text-sm">Repeats</label>
<select
className="border p-1 rounded w-full"
value={rec.freq}
onChange={e => update({ freq: e.target.value as any })}
>
<option value="NONE">Does not repeat</option>
<option value="DAILY">Daily</option>
<option value="WEEKLY">Weekly</option>
<option value="MONTHLY">Monthly</option>
</select>
{rec.freq !== 'NONE' && (
<>
<label className="block text-sm">
Interval (every N {rec.freq === 'DAILY' ? 'days' : rec.freq === 'WEEKLY' ? 'weeks' : 'months'})
</label>
<Input
type="number"
min={1}
value={rec.interval}
onChange={e => update({ interval: parseInt(e.target.value, 10) || 1 })}
/>
{rec.freq === 'WEEKLY' && (
<div className="flex gap-2 mt-2">
{['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'].map(day => (
<label key={day} className="flex items-center gap-1">
<input
type="checkbox"
checked={rec.byDay?.includes(day)}
onChange={() => toggleDay(day)}
/>
{day}
</label>
))}
</div>
)}
<div className="flex gap-2 mt-2">
<div>
<label className="text-sm">End after count</label>
<Input
type="number"
placeholder="e.g. 10"
value={rec.count || ''}
onChange={e => update({ count: e.target.value ? parseInt(e.target.value, 10) : undefined })}
/>
</div>
<div>
<label className="text-sm">End by date</label>
<Input
type="date"
value={rec.until || ''}
onChange={e => update({ until: e.target.value || undefined })}
/>
</div>
</div>
</>
)}
</div>
)
}