{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "calendar-planner", "type": "registry:ui", "title": "Calendar Planner", "description": "Vertical day stream — past fades, today glows, events live inline. Select a day, type, press Enter.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/calendar-planner.tsx", "content": "\"use client\";\n\nimport { useRef, useState, useMemo, useEffect, useCallback } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Calendar Planner — a vertical stream of days.\n *\n * No grid. Time flows downward like a notebook.\n * Past days fade, today glows with an accent bar,\n * future days wait quietly. Events live inline,\n * right next to the date they belong to.\n * Select a day, type, press Enter.\n *\n * The stream IS the schedule.\n */\n\n/* ── Types ── */\n\nexport interface PlannerEvent {\n id: string;\n title: string;\n date: string; // \"YYYY-MM-DD\"\n}\n\nexport interface CalendarPlannerProps {\n events?: PlannerEvent[];\n onAdd?: (event: PlannerEvent) => void;\n onRemove?: (id: string) => void;\n sound?: boolean;\n}\n\n/* ── Constants ── */\n\nconst DAY_NAMES = [\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\"];\n\n/* ── Helpers ── */\n\nfunction pad2(n: number): string {\n return String(n).padStart(2, \"0\");\n}\n\nfunction toKey(y: number, m: number, d: number): string {\n return `${y}-${pad2(m + 1)}-${pad2(d)}`;\n}\n\nfunction getDayName(y: number, m: number, d: number): string {\n return DAY_NAMES[(new Date(y, m, d).getDay() + 6) % 7];\n}\n\nfunction isWeekend(y: number, m: number, d: number): boolean {\n const dow = new Date(y, m, d).getDay();\n return dow === 0 || dow === 6;\n}\n\n/* ── Audio ── */\n\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\n\nfunction audioCtx() {\n if (!_ctx) {\n _ctx = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n }\n if (_ctx.state === \"suspended\") _ctx.resume();\n return _ctx;\n}\n\nfunction ensureBuf(ac: AudioContext): AudioBuffer {\n if (_buf && _buf.sampleRate === ac.sampleRate) return _buf;\n const rate = ac.sampleRate;\n const len = Math.floor(rate * 0.003);\n const buf = ac.createBuffer(1, len, rate);\n const ch = buf.getChannelData(0);\n for (let i = 0; i < len; i++) {\n const t = i / len;\n ch[i] = (Math.random() * 2 - 1) * (1 - t) ** 4;\n }\n _buf = buf;\n return buf;\n}\n\nfunction playTick(last: React.MutableRefObject) {\n const now = performance.now();\n if (now - last.current < 80) return;\n last.current = now;\n try {\n const ac = audioCtx();\n const buf = ensureBuf(ac);\n const src = ac.createBufferSource();\n const gain = ac.createGain();\n src.buffer = buf;\n src.playbackRate.value = 1.15;\n gain.gain.value = 0.03;\n src.connect(gain);\n gain.connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Component ── */\n\nexport function CalendarPlanner({\n events: initialEvents = [],\n onAdd,\n onRemove,\n sound = true,\n}: CalendarPlannerProps) {\n const [events, setEvents] = useState(initialEvents);\n const [month, setMonth] = useState(() => new Date().getMonth());\n const [year, setYear] = useState(() => new Date().getFullYear());\n const [selectedDay, setSelectedDay] = useState(null);\n const [title, setTitle] = useState(\"\");\n const [direction, setDirection] = useState(1);\n const inputRef = useRef(null);\n const scrollRef = useRef(null);\n const lastSound = useRef(0);\n\n function tick() {\n if (sound) playTick(lastSound);\n }\n\n /* ── Calendar math ── */\n\n const daysInMonth = new Date(year, month + 1, 0).getDate();\n const now = new Date();\n const todayDay =\n now.getMonth() === month && now.getFullYear() === year\n ? now.getDate()\n : null;\n\n /* ── Event lookup ── */\n\n const prefix = `${year}-${pad2(month + 1)}`;\n const eventsByDay = useMemo(() => {\n const map = new Map();\n for (const e of events) {\n if (e.date.startsWith(prefix)) {\n const d = parseInt(e.date.slice(8), 10);\n if (d >= 1 && d <= daysInMonth) {\n const arr = map.get(d) || [];\n arr.push(e);\n map.set(d, arr);\n }\n }\n }\n return map;\n }, [events, prefix, daysInMonth]);\n\n /* ── Auto-scroll to today ── */\n\n const todayScrollRef = useCallback(\n (el: HTMLDivElement | null) => {\n if (el) {\n setTimeout(() => {\n el.scrollIntoView({ block: \"center\", behavior: \"smooth\" });\n }, 300);\n }\n },\n // Re-run when month/year changes\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [month, year],\n );\n\n /* Reset scroll on month change when today isn't in view */\n useEffect(() => {\n if (todayDay === null && scrollRef.current) {\n scrollRef.current.scrollTop = 0;\n }\n }, [month, year, todayDay]);\n\n /* ── Month label ── */\n\n const monthName = new Date(year, month).toLocaleDateString(\"en-US\", {\n month: \"long\",\n });\n\n /* ── Navigation ── */\n\n function goMonth(delta: number) {\n tick();\n setDirection(delta);\n let m = month + delta;\n let y = year;\n if (m < 0) {\n m = 11;\n y--;\n } else if (m > 11) {\n m = 0;\n y++;\n }\n setMonth(m);\n setYear(y);\n setSelectedDay(null);\n setTitle(\"\");\n }\n\n /* ── Add / Remove ── */\n\n function handleAdd() {\n const trimmed = title.trim();\n if (!trimmed || selectedDay === null) return;\n tick();\n const ev: PlannerEvent = {\n id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,\n title: trimmed,\n date: toKey(year, month, selectedDay),\n };\n setEvents((prev) => [...prev, ev]);\n onAdd?.(ev);\n setTitle(\"\");\n inputRef.current?.focus();\n }\n\n function handleRemove(id: string) {\n tick();\n setEvents((prev) => prev.filter((e) => e.id !== id));\n onRemove?.(id);\n }\n\n return (\n \n {/* Header */}\n \n goMonth(-1)}\n className=\"cursor-pointer rounded-lg border-none bg-transparent text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-400\"\n style={{\n fontSize: 16,\n lineHeight: 1,\n padding: \"6px 10px\",\n }}\n >\n ‹\n \n\n
\n \n {monthName}\n \n \n {year}\n \n
\n\n goMonth(1)}\n className=\"cursor-pointer rounded-lg border-none bg-transparent text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-400\"\n style={{\n fontSize: 16,\n lineHeight: 1,\n padding: \"6px 10px\",\n }}\n >\n ›\n \n \n\n {/* Separator */}\n \n\n {/* Day stream */}\n \n \n 0 ? 8 : -8 }}\n animate={{ opacity: 1, y: 0 }}\n exit={{ opacity: 0, y: direction > 0 ? -8 : 8 }}\n transition={{ duration: 0.2, ease: \"easeOut\" }}\n style={{ padding: \"8px 0 16px\" }}\n >\n {Array.from({ length: daysInMonth }).map((_, i) => {\n const d = i + 1;\n const isToday = d === todayDay;\n const isPast = todayDay !== null && d < todayDay;\n const isSel = d === selectedDay;\n const dayEvents = eventsByDay.get(d) || [];\n const hasEvents = dayEvents.length > 0;\n const weekend = isWeekend(year, month, d);\n\n const rowOpacity = isPast ? 0.3 : isToday ? 1 : 0.7;\n\n return (\n \n {/* Today micro-label */}\n {isToday && (\n \n Today\n \n )}\n\n {/* Day row — clickable to select */}\n {\n tick();\n setSelectedDay(isSel ? null : d);\n setTitle(\"\");\n if (!isSel) {\n setTimeout(() => inputRef.current?.focus(), 200);\n }\n }}\n className={cn(\n \"cursor-pointer transition-[background] duration-150\",\n isSel\n ? \"bg-neutral-100/50 dark:bg-neutral-800/20\"\n : isToday\n ? \"bg-neutral-50 dark:bg-neutral-900/30\"\n : \"bg-transparent\",\n )}\n style={{\n display: \"flex\",\n alignItems: \"flex-start\",\n padding: `${hasEvents ? 10 : 8}px 24px ${hasEvents ? 10 : 8}px 14px`,\n position: \"relative\",\n }}\n >\n {/* Today accent bar */}\n {isToday && (\n \n )}\n\n {/* Selected accent bar */}\n {isSel && !isToday && (\n \n )}\n\n {/* Date number */}\n \n {d}\n \n\n {/* Day abbreviation */}\n \n {getDayName(year, month, d)}\n \n\n {/* Events / empty */}\n
\n {!hasEvents && (\n \n —\n \n )}\n\n {dayEvents.map((ev) => (\n \n \n \n {ev.title}\n \n {\n e.stopPropagation();\n handleRemove(ev.id);\n }}\n className=\"cursor-pointer border-none bg-transparent text-neutral-200 transition-colors hover:text-red-500 dark:text-neutral-800 dark:hover:text-red-400\"\n style={{\n fontSize: 14,\n lineHeight: 1,\n padding: \"2px 0\",\n flexShrink: 0,\n }}\n >\n ×\n \n
\n ))}\n \n \n\n {/* Inline creation — slides in when selected */}\n \n {isSel && (\n \n \n setTitle(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n handleAdd();\n }\n }}\n onClick={(e) => e.stopPropagation()}\n placeholder=\"Add event…\"\n className=\"text-neutral-600 dark:text-neutral-400\"\n style={{\n flex: 1,\n background: \"transparent\",\n border: \"none\",\n outline: \"none\",\n fontSize: 13,\n fontFamily: \"inherit\",\n }}\n />\n {\n e.stopPropagation();\n handleAdd();\n }}\n className={cn(\n \"border-none bg-transparent transition-colors duration-150\",\n title.trim()\n ? \"cursor-pointer text-neutral-500 dark:text-neutral-500\"\n : \"cursor-default text-neutral-200 dark:text-neutral-800\",\n )}\n style={{\n fontSize: 13,\n fontWeight: 500,\n padding: \"4px 0\",\n }}\n >\n Add\n \n \n \n )}\n \n\n {/* Separator */}\n {d < daysInMonth && (\n \n )}\n \n );\n })}\n \n
\n \n \n );\n}\n\nexport default CalendarPlanner;\n", "type": "registry:ui", "target": "components/ruixen/calendar-planner.tsx" } ] }