feat: add unified calendar tool with switchable views

- Add CalendarShapeUtil with view tabs (Browser/Widget/Year)
- Add CalendarTool for placing calendar on canvas
- Add CalendarEventShapeUtil for spawning event cards
- Add CalendarPanel component with month/week views
- Add YearViewPanel component with 12-month grid
- Add useCalendarEvents hook for fetching encrypted calendar data
- Single keyboard shortcut (Ctrl+Alt+K) with in-shape view switching
- Auto-resize when switching between views

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-23 11:02:13 -05:00
parent 9f2cc9267e
commit 4bf46a34e6
9 changed files with 2721 additions and 0 deletions

View File

@ -0,0 +1,670 @@
// Calendar Panel - Month/Week view with event list
// Used inside CalendarBrowserShape
import React, { useState, useMemo, useCallback } from "react"
import {
useCalendarEvents,
type DecryptedCalendarEvent,
} from "@/hooks/useCalendarEvents"
interface CalendarPanelProps {
onClose?: () => void
onEventSelect?: (event: DecryptedCalendarEvent) => void
shapeMode?: boolean
initialView?: "month" | "week"
initialDate?: Date
}
type ViewMode = "month" | "week"
// Helper functions
const getDaysInMonth = (year: number, month: number) => {
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (year: number, month: number) => {
const day = new Date(year, month, 1).getDay()
// Convert Sunday (0) to 7 for Monday-first week
return day === 0 ? 6 : day - 1
}
const formatMonthYear = (date: Date) => {
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" })
}
const isSameDay = (date1: Date, date2: Date) => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
)
}
const isToday = (date: Date) => {
return isSameDay(date, new Date())
}
export function CalendarPanel({
onClose,
onEventSelect,
shapeMode = false,
initialView = "month",
initialDate,
}: CalendarPanelProps) {
const [viewMode, setViewMode] = useState<ViewMode>(initialView)
const [currentDate, setCurrentDate] = useState(initialDate || new Date())
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
// Detect dark mode
const isDarkMode =
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
// Get calendar events for the visible range
const startOfVisibleRange = useMemo(() => {
const year = currentDate.getFullYear()
const month = currentDate.getMonth()
// Start from previous month to show leading days
return new Date(year, month - 1, 1)
}, [currentDate])
const endOfVisibleRange = useMemo(() => {
const year = currentDate.getFullYear()
const month = currentDate.getMonth()
// End at next month to show trailing days
return new Date(year, month + 2, 0)
}, [currentDate])
const { events, loading, error, refresh, getEventsForDate, getUpcoming } =
useCalendarEvents({
startDate: startOfVisibleRange,
endDate: endOfVisibleRange,
})
// Colors
const colors = isDarkMode
? {
bg: "#1a1a1a",
cardBg: "#252525",
headerBg: "#22c55e",
text: "#e4e4e7",
textMuted: "#a1a1aa",
border: "#404040",
todayBg: "#22c55e20",
selectedBg: "#3b82f620",
eventDot: "#3b82f6",
buttonBg: "#374151",
buttonHover: "#4b5563",
}
: {
bg: "#ffffff",
cardBg: "#f9fafb",
headerBg: "#22c55e",
text: "#1f2937",
textMuted: "#6b7280",
border: "#e5e7eb",
todayBg: "#22c55e15",
selectedBg: "#3b82f615",
eventDot: "#3b82f6",
buttonBg: "#f3f4f6",
buttonHover: "#e5e7eb",
}
// Navigation handlers
const goToPrevious = () => {
if (viewMode === "month") {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)
)
} else {
const newDate = new Date(currentDate)
newDate.setDate(newDate.getDate() - 7)
setCurrentDate(newDate)
}
}
const goToNext = () => {
if (viewMode === "month") {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
)
} else {
const newDate = new Date(currentDate)
newDate.setDate(newDate.getDate() + 7)
setCurrentDate(newDate)
}
}
const goToToday = () => {
setCurrentDate(new Date())
setSelectedDate(new Date())
}
// Generate month grid
const monthGrid = useMemo(() => {
const year = currentDate.getFullYear()
const month = currentDate.getMonth()
const daysInMonth = getDaysInMonth(year, month)
const firstDay = getFirstDayOfMonth(year, month)
const days: { date: Date; isCurrentMonth: boolean }[] = []
// Previous month days
const prevMonth = month === 0 ? 11 : month - 1
const prevYear = month === 0 ? year - 1 : year
const daysInPrevMonth = getDaysInMonth(prevYear, prevMonth)
for (let i = firstDay - 1; i >= 0; i--) {
days.push({
date: new Date(prevYear, prevMonth, daysInPrevMonth - i),
isCurrentMonth: false,
})
}
// Current month days
for (let i = 1; i <= daysInMonth; i++) {
days.push({
date: new Date(year, month, i),
isCurrentMonth: true,
})
}
// Next month days to complete grid
const nextMonth = month === 11 ? 0 : month + 1
const nextYear = month === 11 ? year + 1 : year
const remainingDays = 42 - days.length // 6 rows * 7 days
for (let i = 1; i <= remainingDays; i++) {
days.push({
date: new Date(nextYear, nextMonth, i),
isCurrentMonth: false,
})
}
return days
}, [currentDate])
// Format event time
const formatEventTime = (event: DecryptedCalendarEvent) => {
if (event.isAllDay) return "All day"
return event.startTime.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
}
// Upcoming events for sidebar
const upcomingEvents = useMemo(() => {
return getUpcoming(10)
}, [getUpcoming])
// Events for selected date
const selectedDateEvents = useMemo(() => {
if (!selectedDate) return []
return getEventsForDate(selectedDate)
}, [selectedDate, getEventsForDate])
// Day cell component
const DayCell = ({
date,
isCurrentMonth,
}: {
date: Date
isCurrentMonth: boolean
}) => {
const dayEvents = getEventsForDate(date)
const isSelectedDate = selectedDate && isSameDay(date, selectedDate)
const isTodayDate = isToday(date)
return (
<div
onClick={() => setSelectedDate(date)}
style={{
padding: "4px",
minHeight: "60px",
cursor: "pointer",
backgroundColor: isSelectedDate
? colors.selectedBg
: isTodayDate
? colors.todayBg
: "transparent",
borderRadius: "4px",
border: isTodayDate
? `2px solid ${colors.headerBg}`
: "1px solid transparent",
transition: "background-color 0.15s ease",
}}
onMouseEnter={(e) => {
if (!isSelectedDate && !isTodayDate) {
e.currentTarget.style.backgroundColor = colors.buttonBg
}
}}
onMouseLeave={(e) => {
if (!isSelectedDate && !isTodayDate) {
e.currentTarget.style.backgroundColor = "transparent"
}
}}
>
<div
style={{
fontSize: "12px",
fontWeight: isTodayDate ? "700" : "500",
color: isCurrentMonth ? colors.text : colors.textMuted,
marginBottom: "4px",
}}
>
{date.getDate()}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px" }}>
{dayEvents.slice(0, 3).map((event, i) => (
<div
key={event.id}
style={{
width: "6px",
height: "6px",
borderRadius: "50%",
backgroundColor: colors.eventDot,
}}
title={event.summary}
/>
))}
{dayEvents.length > 3 && (
<div
style={{
fontSize: "9px",
color: colors.textMuted,
}}
>
+{dayEvents.length - 3}
</div>
)}
</div>
</div>
)
}
// Event list item
const EventItem = ({ event }: { event: DecryptedCalendarEvent }) => (
<div
onClick={() => onEventSelect?.(event)}
style={{
padding: "10px 12px",
backgroundColor: colors.cardBg,
borderRadius: "8px",
cursor: "pointer",
borderLeft: `3px solid ${colors.eventDot}`,
transition: "background-color 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = colors.buttonBg
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = colors.cardBg
}}
>
<div
style={{
fontSize: "13px",
fontWeight: "600",
color: colors.text,
marginBottom: "4px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{event.summary}
</div>
<div
style={{
fontSize: "11px",
color: colors.textMuted,
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span>{formatEventTime(event)}</span>
{event.location && (
<>
<span>|</span>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{event.location}
</span>
</>
)}
</div>
</div>
)
// Loading state
if (loading) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: colors.textMuted,
}}
>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>Loading...</div>
<div style={{ fontSize: "12px" }}>Fetching calendar events</div>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: colors.textMuted,
}}
>
<div style={{ textAlign: "center", padding: "20px" }}>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>Error</div>
<div style={{ fontSize: "12px", marginBottom: "16px" }}>{error}</div>
<button
onClick={refresh}
style={{
padding: "8px 16px",
backgroundColor: colors.headerBg,
color: "#fff",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "13px",
}}
>
Retry
</button>
</div>
</div>
)
}
// No events state
if (events.length === 0) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: colors.textMuted,
}}
>
<div style={{ textAlign: "center", padding: "20px" }}>
<div style={{ fontSize: "48px", marginBottom: "16px" }}>📅</div>
<div style={{ fontSize: "16px", fontWeight: "600", marginBottom: "8px" }}>
No Calendar Events
</div>
<div style={{ fontSize: "12px", marginBottom: "16px" }}>
Import your Google Calendar to see events here.
</div>
</div>
</div>
)
}
return (
<div
style={{
display: "flex",
height: "100%",
backgroundColor: colors.bg,
color: colors.text,
}}
>
{/* Main Calendar Area */}
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
borderRight: `1px solid ${colors.border}`,
}}
>
{/* Navigation Header */}
<div
style={{
padding: "12px 16px",
display: "flex",
alignItems: "center",
gap: "12px",
borderBottom: `1px solid ${colors.border}`,
}}
>
<button
onClick={goToPrevious}
style={{
padding: "6px 12px",
backgroundColor: colors.buttonBg,
color: colors.text,
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "14px",
}}
>
&lt;
</button>
<div
style={{
flex: 1,
textAlign: "center",
fontSize: "16px",
fontWeight: "600",
}}
>
{formatMonthYear(currentDate)}
</div>
<button
onClick={goToNext}
style={{
padding: "6px 12px",
backgroundColor: colors.buttonBg,
color: colors.text,
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "14px",
}}
>
&gt;
</button>
<button
onClick={goToToday}
style={{
padding: "6px 12px",
backgroundColor: colors.headerBg,
color: "#fff",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "12px",
fontWeight: "600",
}}
>
Today
</button>
{/* View toggle */}
<div
style={{
display: "flex",
backgroundColor: colors.buttonBg,
borderRadius: "6px",
overflow: "hidden",
}}
>
<button
onClick={() => setViewMode("month")}
style={{
padding: "6px 12px",
backgroundColor:
viewMode === "month" ? colors.headerBg : "transparent",
color: viewMode === "month" ? "#fff" : colors.text,
border: "none",
cursor: "pointer",
fontSize: "12px",
}}
>
Month
</button>
<button
onClick={() => setViewMode("week")}
style={{
padding: "6px 12px",
backgroundColor:
viewMode === "week" ? colors.headerBg : "transparent",
color: viewMode === "week" ? "#fff" : colors.text,
border: "none",
cursor: "pointer",
fontSize: "12px",
}}
>
Week
</button>
</div>
</div>
{/* Calendar Grid */}
<div style={{ flex: 1, padding: "12px", overflow: "auto" }}>
{/* Day headers */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "4px",
marginBottom: "8px",
}}
>
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((day) => (
<div
key={day}
style={{
textAlign: "center",
fontSize: "11px",
fontWeight: "600",
color: colors.textMuted,
padding: "4px",
}}
>
{day}
</div>
))}
</div>
{/* Day cells */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "4px",
}}
>
{monthGrid.map(({ date, isCurrentMonth }, i) => (
<DayCell key={i} date={date} isCurrentMonth={isCurrentMonth} />
))}
</div>
</div>
</div>
{/* Sidebar - Events */}
<div
style={{
width: "280px",
display: "flex",
flexDirection: "column",
}}
>
{/* Selected Date Events or Upcoming */}
<div
style={{
flex: 1,
padding: "12px",
overflow: "auto",
}}
>
<div
style={{
fontSize: "12px",
fontWeight: "600",
color: colors.textMuted,
marginBottom: "12px",
textTransform: "uppercase",
letterSpacing: "0.5px",
}}
>
{selectedDate
? selectedDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})
: "Upcoming Events"}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{(selectedDate ? selectedDateEvents : upcomingEvents).map(
(event) => (
<EventItem key={event.id} event={event} />
)
)}
{(selectedDate ? selectedDateEvents : upcomingEvents).length ===
0 && (
<div
style={{
textAlign: "center",
padding: "20px",
color: colors.textMuted,
fontSize: "12px",
}}
>
{selectedDate
? "No events on this day"
: "No upcoming events"}
</div>
)}
</div>
</div>
{/* Click hint */}
{onEventSelect && (
<div
style={{
padding: "12px",
borderTop: `1px solid ${colors.border}`,
fontSize: "11px",
color: colors.textMuted,
textAlign: "center",
}}
>
Click an event to add it to the canvas
</div>
)}
</div>
</div>
)
}
export default CalendarPanel

View File

@ -0,0 +1,420 @@
// Year View Panel - KalNext-style 12-month yearly overview
// Shows all months in a 4x3 grid with event density indicators
import React, { useState, useMemo } from "react"
import { useCalendarEvents, type DecryptedCalendarEvent } from "@/hooks/useCalendarEvents"
interface YearViewPanelProps {
onClose?: () => void
onMonthSelect?: (year: number, month: number) => void
shapeMode?: boolean
initialYear?: number
}
// Helper functions
const getDaysInMonth = (year: number, month: number) => {
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (year: number, month: number) => {
const day = new Date(year, month, 1).getDay()
return day === 0 ? 6 : day - 1 // Monday-first
}
const isSameDay = (date1: Date, date2: Date) => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
)
}
const MONTH_NAMES = [
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
]
const SHORT_MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr",
"May", "Jun", "Jul", "Aug",
"Sep", "Oct", "Nov", "Dec"
]
export const YearViewPanel: React.FC<YearViewPanelProps> = ({
onClose,
onMonthSelect,
shapeMode = false,
initialYear,
}) => {
const [currentYear, setCurrentYear] = useState(initialYear || new Date().getFullYear())
// Detect dark mode
const isDarkMode =
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
// Fetch all events for the current year
const yearStart = new Date(currentYear, 0, 1)
const yearEnd = new Date(currentYear, 11, 31, 23, 59, 59)
const { events, loading, getEventsForDate } = useCalendarEvents({
startDate: yearStart,
endDate: yearEnd,
})
// Colors
const colors = isDarkMode
? {
bg: "#1f2937",
text: "#e4e4e7",
textMuted: "#a1a1aa",
border: "#374151",
monthBg: "#252525",
todayBg: "#22c55e30",
todayBorder: "#22c55e",
eventDot1: "#3b82f620", // 1 event
eventDot2: "#3b82f640", // 2 events
eventDot3: "#3b82f680", // 3+ events
eventDotMax: "#3b82f6", // 5+ events
headerBg: "#22c55e",
}
: {
bg: "#f9fafb",
text: "#1f2937",
textMuted: "#6b7280",
border: "#e5e7eb",
monthBg: "#ffffff",
todayBg: "#22c55e20",
todayBorder: "#22c55e",
eventDot1: "#3b82f620",
eventDot2: "#3b82f640",
eventDot3: "#3b82f680",
eventDotMax: "#3b82f6",
headerBg: "#22c55e",
}
// Get event count for a specific date
const getEventCount = (date: Date) => {
return getEventsForDate(date).length
}
// Get background color based on event density
const getEventDensityColor = (count: number) => {
if (count === 0) return "transparent"
if (count === 1) return colors.eventDot1
if (count === 2) return colors.eventDot2
if (count <= 4) return colors.eventDot3
return colors.eventDotMax
}
// Navigation
const goToPrevYear = () => setCurrentYear((y) => y - 1)
const goToNextYear = () => setCurrentYear((y) => y + 1)
const goToCurrentYear = () => setCurrentYear(new Date().getFullYear())
const today = new Date()
// Generate mini calendar for a month
const renderMiniMonth = (month: number) => {
const daysInMonth = getDaysInMonth(currentYear, month)
const firstDay = getFirstDayOfMonth(currentYear, month)
const days: { day: number | null; date: Date | null }[] = []
// Leading empty cells
for (let i = 0; i < firstDay; i++) {
days.push({ day: null, date: null })
}
// Days of month
for (let i = 1; i <= daysInMonth; i++) {
days.push({ day: i, date: new Date(currentYear, month, i) })
}
// Trailing empty cells to complete grid (6 rows max)
while (days.length < 42) {
days.push({ day: null, date: null })
}
return (
<div
key={month}
style={{
backgroundColor: colors.monthBg,
borderRadius: "8px",
padding: "8px",
border: `1px solid ${colors.border}`,
cursor: onMonthSelect ? "pointer" : "default",
}}
onClick={() => onMonthSelect?.(currentYear, month)}
onPointerDown={(e) => e.stopPropagation()}
>
{/* Month name */}
<div
style={{
fontSize: "11px",
fontWeight: "600",
color: colors.text,
marginBottom: "6px",
textAlign: "center",
}}
>
{SHORT_MONTH_NAMES[month]}
</div>
{/* Day headers */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "1px",
marginBottom: "2px",
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((day, i) => (
<div
key={i}
style={{
textAlign: "center",
fontSize: "7px",
fontWeight: "500",
color: colors.textMuted,
}}
>
{day}
</div>
))}
</div>
{/* Days grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "1px",
}}
>
{days.slice(0, 42).map(({ day, date }, i) => {
const isToday = date && isSameDay(date, today)
const eventCount = date ? getEventCount(date) : 0
const densityColor = getEventDensityColor(eventCount)
return (
<div
key={i}
style={{
textAlign: "center",
fontSize: "8px",
padding: "2px 0",
borderRadius: "2px",
backgroundColor: isToday ? colors.todayBg : densityColor,
border: isToday ? `1px solid ${colors.todayBorder}` : "1px solid transparent",
color: day ? colors.text : "transparent",
fontWeight: isToday ? "700" : "400",
minHeight: "14px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{day}
</div>
)
})}
</div>
</div>
)
}
return (
<div
style={{
width: "100%",
height: "100%",
backgroundColor: colors.bg,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Header with year navigation */}
<div
style={{
padding: "12px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: `1px solid ${colors.border}`,
backgroundColor: colors.headerBg,
}}
>
<button
onClick={goToPrevYear}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: "rgba(255, 255, 255, 0.2)",
border: "none",
color: "#fff",
width: "32px",
height: "32px",
borderRadius: "6px",
cursor: "pointer",
fontSize: "14px",
fontWeight: "600",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
&lt;
</button>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<span
style={{
fontSize: "18px",
fontWeight: "700",
color: "#fff",
}}
>
{currentYear}
</span>
{currentYear !== new Date().getFullYear() && (
<button
onClick={goToCurrentYear}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: "rgba(255, 255, 255, 0.2)",
border: "none",
color: "#fff",
padding: "4px 10px",
borderRadius: "4px",
cursor: "pointer",
fontSize: "11px",
fontWeight: "500",
}}
>
Today
</button>
)}
</div>
<button
onClick={goToNextYear}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: "rgba(255, 255, 255, 0.2)",
border: "none",
color: "#fff",
width: "32px",
height: "32px",
borderRadius: "6px",
cursor: "pointer",
fontSize: "14px",
fontWeight: "600",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
&gt;
</button>
</div>
{/* 12-month grid (4x3 layout) */}
<div
style={{
flex: 1,
padding: "12px",
overflow: "auto",
}}
>
{loading ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: colors.textMuted,
fontSize: "13px",
}}
>
Loading calendar data...
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gridTemplateRows: "repeat(3, 1fr)",
gap: "10px",
height: "100%",
}}
>
{Array.from({ length: 12 }, (_, month) => renderMiniMonth(month))}
</div>
)}
</div>
{/* Legend */}
<div
style={{
padding: "10px 16px",
borderTop: `1px solid ${colors.border}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "16px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<div
style={{
width: "12px",
height: "12px",
borderRadius: "2px",
backgroundColor: colors.todayBg,
border: `1px solid ${colors.todayBorder}`,
}}
/>
<span style={{ fontSize: "10px", color: colors.textMuted }}>Today</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<div
style={{
width: "12px",
height: "12px",
borderRadius: "2px",
backgroundColor: colors.eventDot1,
}}
/>
<span style={{ fontSize: "10px", color: colors.textMuted }}>1 event</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<div
style={{
width: "12px",
height: "12px",
borderRadius: "2px",
backgroundColor: colors.eventDot3,
}}
/>
<span style={{ fontSize: "10px", color: colors.textMuted }}>3+ events</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<div
style={{
width: "12px",
height: "12px",
borderRadius: "2px",
backgroundColor: colors.eventDotMax,
}}
/>
<span style={{ fontSize: "10px", color: colors.textMuted }}>5+ events</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,341 @@
// Hook for accessing decrypted calendar events from local encrypted storage
// Uses the existing Google Data Sovereignty infrastructure
import { useState, useEffect, useCallback, useMemo } from 'react'
import { calendarStore } from '@/lib/google/database'
import { deriveServiceKey, decryptDataToString, importMasterKey } from '@/lib/google/encryption'
import { getGoogleDataService } from '@/lib/google'
import type { EncryptedCalendarEvent } from '@/lib/google/types'
// Decrypted event type for display
export interface DecryptedCalendarEvent {
id: string
calendarId: string
summary: string
description: string | null
location: string | null
startTime: Date
endTime: Date
isAllDay: boolean
timezone: string
isRecurring: boolean
meetingLink: string | null
reminders: { method: string; minutes: number }[]
syncedAt: number
}
// Hook options
export interface UseCalendarEventsOptions {
startDate?: Date
endDate?: Date
limit?: number
calendarId?: string
autoRefresh?: boolean
refreshInterval?: number // in milliseconds
}
// Hook return type
export interface UseCalendarEventsResult {
events: DecryptedCalendarEvent[]
loading: boolean
error: string | null
initialized: boolean
refresh: () => Promise<void>
getEventsForDate: (date: Date) => DecryptedCalendarEvent[]
getEventsForMonth: (year: number, month: number) => DecryptedCalendarEvent[]
getEventsForWeek: (date: Date) => DecryptedCalendarEvent[]
getUpcoming: (limit?: number) => DecryptedCalendarEvent[]
eventCount: number
}
// Helper to get start of day
function startOfDay(date: Date): Date {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
return d
}
// Helper to get end of day
function endOfDay(date: Date): Date {
const d = new Date(date)
d.setHours(23, 59, 59, 999)
return d
}
// Helper to get start of week (Monday)
function startOfWeek(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1) // Monday as first day
d.setDate(diff)
d.setHours(0, 0, 0, 0)
return d
}
// Helper to get end of week (Sunday)
function endOfWeek(date: Date): Date {
const start = startOfWeek(date)
const end = new Date(start)
end.setDate(end.getDate() + 6)
end.setHours(23, 59, 59, 999)
return end
}
// Helper to get start of month
function startOfMonth(year: number, month: number): Date {
return new Date(year, month, 1, 0, 0, 0, 0)
}
// Helper to get end of month
function endOfMonth(year: number, month: number): Date {
return new Date(year, month + 1, 0, 23, 59, 59, 999)
}
// Decrypt a single event
async function decryptEvent(
event: EncryptedCalendarEvent,
calendarKey: CryptoKey
): Promise<DecryptedCalendarEvent> {
const [summary, description, location, meetingLink] = await Promise.all([
decryptDataToString(event.encryptedSummary, calendarKey),
event.encryptedDescription
? decryptDataToString(event.encryptedDescription, calendarKey)
: Promise.resolve(null),
event.encryptedLocation
? decryptDataToString(event.encryptedLocation, calendarKey)
: Promise.resolve(null),
event.encryptedMeetingLink
? decryptDataToString(event.encryptedMeetingLink, calendarKey)
: Promise.resolve(null),
])
return {
id: event.id,
calendarId: event.calendarId,
summary,
description,
location,
startTime: new Date(event.startTime),
endTime: new Date(event.endTime),
isAllDay: event.isAllDay,
timezone: event.timezone,
isRecurring: event.isRecurring,
meetingLink,
reminders: event.reminders || [],
syncedAt: event.syncedAt,
}
}
export function useCalendarEvents(
options: UseCalendarEventsOptions = {}
): UseCalendarEventsResult {
const {
startDate,
endDate,
limit,
calendarId,
autoRefresh = false,
refreshInterval = 60000, // 1 minute default
} = options
const [events, setEvents] = useState<DecryptedCalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [initialized, setInitialized] = useState(false)
// Fetch and decrypt events
const fetchEvents = useCallback(async () => {
setLoading(true)
setError(null)
try {
const service = getGoogleDataService()
// Check if service is initialized
if (!service.isInitialized()) {
// Try to initialize
const success = await service.initialize()
if (!success) {
setEvents([])
setInitialized(false)
setLoading(false)
return
}
}
// Get the master key
const masterKeyData = await service.exportKey()
if (!masterKeyData) {
setError('No encryption key available')
setEvents([])
setLoading(false)
return
}
// Derive calendar-specific key
const masterKey = await importMasterKey(masterKeyData)
const calendarKey = await deriveServiceKey(masterKey, 'calendar')
// Determine query range
let encryptedEvents: EncryptedCalendarEvent[]
if (startDate && endDate) {
// Query by date range
encryptedEvents = await calendarStore.getByDateRange(
startDate.getTime(),
endDate.getTime()
)
} else if (calendarId) {
// Query by calendar ID
encryptedEvents = await calendarStore.getByCalendar(calendarId)
} else {
// Get upcoming events (default: next 90 days)
const now = Date.now()
const ninetyDaysLater = now + 90 * 24 * 60 * 60 * 1000
encryptedEvents = await calendarStore.getByDateRange(now, ninetyDaysLater)
}
// Apply limit if specified
if (limit && encryptedEvents.length > limit) {
encryptedEvents = encryptedEvents.slice(0, limit)
}
// Decrypt all events in parallel
const decryptedEvents = await Promise.all(
encryptedEvents.map(event => decryptEvent(event, calendarKey))
)
// Sort by start time
decryptedEvents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
setEvents(decryptedEvents)
setInitialized(true)
} catch (err) {
console.error('Failed to fetch calendar events:', err)
setError(err instanceof Error ? err.message : 'Failed to load calendar events')
setEvents([])
} finally {
setLoading(false)
}
}, [startDate, endDate, limit, calendarId])
// Initial fetch
useEffect(() => {
fetchEvents()
}, [fetchEvents])
// Auto-refresh if enabled
useEffect(() => {
if (!autoRefresh || !initialized) return
const intervalId = setInterval(fetchEvents, refreshInterval)
return () => clearInterval(intervalId)
}, [autoRefresh, refreshInterval, fetchEvents, initialized])
// Get events for a specific date
const getEventsForDate = useCallback(
(date: Date): DecryptedCalendarEvent[] => {
const dayStart = startOfDay(date).getTime()
const dayEnd = endOfDay(date).getTime()
return events.filter(event => {
const eventStart = event.startTime.getTime()
const eventEnd = event.endTime.getTime()
// Event overlaps with this day
return eventStart <= dayEnd && eventEnd >= dayStart
})
},
[events]
)
// Get events for a specific month
const getEventsForMonth = useCallback(
(year: number, month: number): DecryptedCalendarEvent[] => {
const monthStart = startOfMonth(year, month).getTime()
const monthEnd = endOfMonth(year, month).getTime()
return events.filter(event => {
const eventStart = event.startTime.getTime()
const eventEnd = event.endTime.getTime()
// Event overlaps with this month
return eventStart <= monthEnd && eventEnd >= monthStart
})
},
[events]
)
// Get events for a specific week
const getEventsForWeek = useCallback(
(date: Date): DecryptedCalendarEvent[] => {
const weekStart = startOfWeek(date).getTime()
const weekEnd = endOfWeek(date).getTime()
return events.filter(event => {
const eventStart = event.startTime.getTime()
const eventEnd = event.endTime.getTime()
// Event overlaps with this week
return eventStart <= weekEnd && eventEnd >= weekStart
})
},
[events]
)
// Get upcoming events from now
const getUpcoming = useCallback(
(upcomingLimit: number = 10): DecryptedCalendarEvent[] => {
const now = Date.now()
return events
.filter(event => event.startTime.getTime() >= now)
.slice(0, upcomingLimit)
},
[events]
)
// Memoized event count
const eventCount = useMemo(() => events.length, [events])
return {
events,
loading,
error,
initialized,
refresh: fetchEvents,
getEventsForDate,
getEventsForMonth,
getEventsForWeek,
getUpcoming,
eventCount,
}
}
// Hook for getting events for a specific year (useful for YearView)
export function useCalendarEventsForYear(year: number) {
const startDate = useMemo(() => new Date(year, 0, 1), [year])
const endDate = useMemo(() => new Date(year, 11, 31, 23, 59, 59, 999), [year])
return useCalendarEvents({ startDate, endDate })
}
// Hook for getting events for current month
export function useCurrentMonthEvents() {
const now = new Date()
const startDate = useMemo(() => startOfMonth(now.getFullYear(), now.getMonth()), [])
const endDate = useMemo(() => endOfMonth(now.getFullYear(), now.getMonth()), [])
return useCalendarEvents({ startDate, endDate })
}
// Hook for getting upcoming events only
export function useUpcomingEvents(limit: number = 10) {
const startDate = useMemo(() => new Date(), [])
const endDate = useMemo(() => {
const d = new Date()
d.setDate(d.getDate() + 90) // Next 90 days
return d
}, [])
return useCalendarEvents({ startDate, endDate, limit })
}

View File

@ -64,6 +64,15 @@ import { GoogleItemTool } from "@/tools/GoogleItemTool"
// Open Mapping - OSM map shape for geographic visualization
import { MapShape } from "@/shapes/MapShapeUtil"
import { MapTool } from "@/tools/MapTool"
// Workflow Builder - Flowy-like workflow blocks
import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil"
import { WorkflowBlockTool } from "@/tools/WorkflowBlockTool"
// Calendar - Unified calendar with view switching (browser, widget, year)
import { CalendarShape } from "@/shapes/CalendarShapeUtil"
import { CalendarTool } from "@/tools/CalendarTool"
import { CalendarEventShape } from "@/shapes/CalendarEventShapeUtil"
import { registerWorkflowPropagator } from "@/propagators/WorkflowPropagator"
import { setupBlockExecutionListener } from "@/lib/workflow/executor"
import {
lockElement,
unlockElement,
@ -84,6 +93,7 @@ import "@/css/anonymous-banner.css"
import "react-cmdk/dist/cmdk.css"
import "@/css/style.css"
import "@/css/obsidian-browser.css"
import "@/css/workflow.css"
// Helper to validate and fix tldraw IndexKey format
// tldraw uses fractional indexing where the first letter encodes integer part length:
@ -164,6 +174,9 @@ const customShapeUtils = [
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
GoogleItemShape, // Individual items from Google Export with privacy badges
MapShape, // Open Mapping - OSM map shape
WorkflowBlockShape, // Workflow Builder - Flowy-like blocks
CalendarShape, // Calendar - Unified with view switching (browser/widget/year)
CalendarEventShape, // Calendar - Individual event cards
]
const customTools = [
ChatBoxTool,
@ -186,6 +199,8 @@ const customTools = [
PrivateWorkspaceTool,
GoogleItemTool,
MapTool, // Open Mapping - OSM map tool
WorkflowBlockTool, // Workflow Builder - click-to-place
CalendarTool, // Calendar - Unified with view switching
]
// Debug: Log tool and shape registration info
@ -1358,6 +1373,10 @@ export function Board() {
ClickPropagator,
])
// Register workflow propagator for real-time data flow
const cleanupWorkflowPropagator = registerWorkflowPropagator(editor)
const cleanupBlockExecution = setupBlockExecutionListener(editor)
// Clean up corrupted shapes that cause "No nearest point found" errors
// This typically happens with draw/line shapes that have no points
try {

View File

@ -0,0 +1,456 @@
// Calendar Event Shape - Individual event cards spawned on the canvas
// Similar to FathomNoteShape but for calendar events
import {
BaseBoxShapeUtil,
HTMLContainer,
TLBaseShape,
createShapeId,
} from "tldraw"
import React from "react"
import type { DecryptedCalendarEvent } from "@/hooks/useCalendarEvents"
type ICalendarEventShape = TLBaseShape<
"CalendarEvent",
{
w: number
h: number
// Event data
eventId: string
calendarId: string
summary: string
description: string | null
location: string | null
startTime: number // timestamp
endTime: number // timestamp
isAllDay: boolean
timezone: string
meetingLink: string | null
// Visual
primaryColor: string
tags: string[]
}
>
export class CalendarEventShape extends BaseBoxShapeUtil<ICalendarEventShape> {
static override type = "CalendarEvent" as const
// Calendar theme color: Green
static readonly PRIMARY_COLOR = "#22c55e"
getDefaultProps(): ICalendarEventShape["props"] {
return {
w: 350,
h: 250,
eventId: "",
calendarId: "",
summary: "Untitled Event",
description: null,
location: null,
startTime: Date.now(),
endTime: Date.now() + 3600000, // 1 hour later
isAllDay: false,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
meetingLink: null,
primaryColor: CalendarEventShape.PRIMARY_COLOR,
tags: ["calendar", "event"],
}
}
override canResize() {
return true
}
// Factory method to create from DecryptedCalendarEvent
static createFromEvent(
event: DecryptedCalendarEvent,
x: number,
y: number,
primaryColor: string = CalendarEventShape.PRIMARY_COLOR
) {
return {
id: createShapeId(`calendar-event-${event.id}`),
type: "CalendarEvent" as const,
x,
y,
props: {
w: 350,
h: 250,
eventId: event.id,
calendarId: event.calendarId,
summary: event.summary,
description: event.description,
location: event.location,
startTime: event.startTime.getTime(),
endTime: event.endTime.getTime(),
isAllDay: event.isAllDay,
timezone: event.timezone,
meetingLink: event.meetingLink,
primaryColor,
tags: ["calendar", "event"],
},
}
}
component(shape: ICalendarEventShape) {
const { w, h, props } = shape
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
// Detect dark mode
const isDarkMode =
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
// Format date and time
const formatDateTime = (timestamp: number, isAllDay: boolean) => {
const date = new Date(timestamp)
const dateStr = date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
})
if (isAllDay) {
return dateStr
}
const timeStr = date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
return `${dateStr} at ${timeStr}`
}
// Format time range
const formatTimeRange = () => {
if (props.isAllDay) {
const startDate = new Date(props.startTime)
const endDate = new Date(props.endTime)
// Check if same day
if (startDate.toDateString() === endDate.toDateString()) {
return `All day - ${startDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})}`
}
return `${startDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})} - ${endDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}`
}
const startDate = new Date(props.startTime)
const endDate = new Date(props.endTime)
const startTimeStr = startDate.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
const endTimeStr = endDate.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
const dateStr = startDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
})
return `${dateStr}\n${startTimeStr} - ${endTimeStr}`
}
// Calculate duration
const getDuration = () => {
const durationMs = props.endTime - props.startTime
const hours = Math.floor(durationMs / (1000 * 60 * 60))
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60))
if (hours === 0) {
return `${minutes}m`
}
if (minutes === 0) {
return `${hours}h`
}
return `${hours}h ${minutes}m`
}
const colors = isDarkMode
? {
bg: "#1a1a1a",
headerBg: props.primaryColor,
text: "#e4e4e7",
textMuted: "#a1a1aa",
border: "#404040",
linkBg: "#3b82f6",
}
: {
bg: "#ffffff",
headerBg: props.primaryColor,
text: "#1f2937",
textMuted: "#6b7280",
border: "#e5e7eb",
linkBg: "#3b82f6",
}
const handleMeetingLinkClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (props.meetingLink) {
window.open(props.meetingLink, "_blank", "noopener,noreferrer")
}
}
return (
<HTMLContainer
style={{
width: w,
height: h,
pointerEvents: "all",
}}
>
<div
style={{
width: "100%",
height: "100%",
backgroundColor: colors.bg,
borderRadius: "12px",
border: `2px solid ${colors.border}`,
boxShadow: isSelected
? `0 0 0 3px ${props.primaryColor}40`
: "0 4px 12px rgba(0, 0, 0, 0.1)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: "box-shadow 0.15s ease",
}}
>
{/* Header */}
<div
style={{
backgroundColor: colors.headerBg,
padding: "12px 16px",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span style={{ fontSize: "18px" }}>
{props.isAllDay ? "📅" : "🕐"}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "14px",
fontWeight: "600",
color: "#ffffff",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{props.summary}
</div>
<div
style={{
fontSize: "11px",
color: "rgba(255, 255, 255, 0.8)",
marginTop: "2px",
}}
>
{props.isAllDay ? "All Day" : getDuration()}
</div>
</div>
</div>
{/* Content */}
<div
style={{
flex: 1,
padding: "16px",
overflow: "auto",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
{/* Time */}
<div>
<div
style={{
fontSize: "10px",
fontWeight: "600",
color: colors.textMuted,
textTransform: "uppercase",
letterSpacing: "0.5px",
marginBottom: "4px",
}}
>
When
</div>
<div
style={{
fontSize: "13px",
color: colors.text,
whiteSpace: "pre-line",
}}
>
{formatTimeRange()}
</div>
</div>
{/* Location */}
{props.location && (
<div>
<div
style={{
fontSize: "10px",
fontWeight: "600",
color: colors.textMuted,
textTransform: "uppercase",
letterSpacing: "0.5px",
marginBottom: "4px",
}}
>
Location
</div>
<div
style={{
fontSize: "13px",
color: colors.text,
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
<span>📍</span>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{props.location}
</span>
</div>
</div>
)}
{/* Description */}
{props.description && (
<div style={{ flex: 1, minHeight: 0 }}>
<div
style={{
fontSize: "10px",
fontWeight: "600",
color: colors.textMuted,
textTransform: "uppercase",
letterSpacing: "0.5px",
marginBottom: "4px",
}}
>
Description
</div>
<div
style={{
fontSize: "12px",
color: colors.text,
lineHeight: "1.5",
overflow: "auto",
maxHeight: "80px",
}}
>
{props.description}
</div>
</div>
)}
{/* Meeting Link */}
{props.meetingLink && (
<button
onClick={handleMeetingLinkClick}
onPointerDown={(e) => e.stopPropagation()}
style={{
marginTop: "auto",
padding: "10px 16px",
backgroundColor: colors.linkBg,
color: "#ffffff",
border: "none",
borderRadius: "8px",
fontSize: "13px",
fontWeight: "600",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
transition: "background-color 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2563eb"
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = colors.linkBg
}}
>
<span>🔗</span>
Join Meeting
</button>
)}
</div>
{/* Footer with tags */}
<div
style={{
padding: "8px 16px",
borderTop: `1px solid ${colors.border}`,
display: "flex",
gap: "6px",
flexWrap: "wrap",
}}
>
{props.tags.map((tag, i) => (
<span
key={i}
style={{
fontSize: "10px",
padding: "3px 8px",
backgroundColor: isDarkMode ? "#374151" : "#f3f4f6",
color: colors.textMuted,
borderRadius: "4px",
}}
>
{tag}
</span>
))}
</div>
</div>
</HTMLContainer>
)
}
indicator(shape: ICalendarEventShape) {
return (
<rect
width={shape.props.w}
height={shape.props.h}
rx={12}
ry={12}
/>
)
}
}

View File

@ -0,0 +1,644 @@
// Unified Calendar Shape - Combines Browser, Widget, and Year views
// User can switch between views using tabs in the header
import {
BaseBoxShapeUtil,
HTMLContainer,
TLBaseShape,
Box,
} from "tldraw"
import React, { useState } from "react"
import { CalendarPanel } from "@/components/CalendarPanel"
import { YearViewPanel } from "@/components/YearViewPanel"
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
import { usePinnedToView } from "@/hooks/usePinnedToView"
import { useMaximize } from "@/hooks/useMaximize"
import { CalendarEventShape } from "./CalendarEventShapeUtil"
import { useUpcomingEvents, type DecryptedCalendarEvent } from "@/hooks/useCalendarEvents"
type CalendarView = "browser" | "widget" | "year"
type ICalendar = TLBaseShape<
"Calendar",
{
w: number
h: number
pinnedToView: boolean
tags: string[]
currentView: CalendarView
calendarView: "month" | "week" // For browser view
currentDate: number // timestamp
}
>
// View size presets
const VIEW_SIZES: Record<CalendarView, { w: number; h: number }> = {
browser: { w: 900, h: 650 },
widget: { w: 320, h: 420 },
year: { w: 900, h: 650 },
}
export class CalendarShape extends BaseBoxShapeUtil<ICalendar> {
static override type = "Calendar" as const
// Calendar theme color: Green
static readonly PRIMARY_COLOR = "#22c55e"
getDefaultProps(): ICalendar["props"] {
return {
w: 900,
h: 650,
pinnedToView: false,
tags: ["calendar"],
currentView: "browser",
calendarView: "month",
currentDate: Date.now(),
}
}
override canResize() {
return true
}
component(shape: ICalendar) {
const { w, h } = shape.props
const [isOpen, setIsOpen] = useState(true)
const [isMinimized, setIsMinimized] = useState(false)
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
// Use the pinning hook
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
// Use the maximize hook
const { isMaximized, toggleMaximize } = useMaximize({
editor: this.editor,
shapeId: shape.id,
currentW: w,
currentH: h,
shapeType: "Calendar",
})
const handleClose = () => {
setIsOpen(false)
this.editor.deleteShape(shape.id)
}
const handleMinimize = () => {
setIsMinimized(!isMinimized)
}
const handlePinToggle = () => {
this.editor.updateShape<ICalendar>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
pinnedToView: !shape.props.pinnedToView,
},
})
}
// Handle view change with size adjustment
const handleViewChange = (newView: CalendarView) => {
const newSize = VIEW_SIZES[newView]
this.editor.updateShape<ICalendar>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
currentView: newView,
w: newSize.w,
h: newSize.h,
},
})
}
// Handle event selection - spawn event card on canvas
const handleEventSelect = (event: DecryptedCalendarEvent) => {
try {
const shapeBounds = this.editor.getShapePageBounds(shape.id)
let startX: number
let startY: number
if (!shapeBounds) {
const viewport = this.editor.getViewportPageBounds()
startX = viewport.x + viewport.w / 2
startY = viewport.y + viewport.h / 2
} else {
const spacing = 30
startX = shapeBounds.x + shapeBounds.w + spacing
startY = shapeBounds.y
}
// Check for existing event shape
const allShapes = this.editor.getCurrentPageShapes()
const existingEventShape = allShapes.find(
(s) =>
s.type === "CalendarEvent" &&
(s as any).props?.eventId === event.id
)
if (existingEventShape) {
this.editor.setSelectedShapes([existingEventShape.id])
const bounds = this.editor.getShapePageBounds(existingEventShape.id)
if (bounds) {
this.editor.zoomToBounds(bounds, {
inset: 50,
animation: { duration: 300, easing: (t) => t * (2 - t) },
})
}
return
}
// Stack new events vertically
const existingCalendarEvents = allShapes.filter(
(s) => s.type === "CalendarEvent"
)
if (existingCalendarEvents.length > 0 && shapeBounds) {
let maxY = startY
existingCalendarEvents.forEach((s) => {
const bounds = this.editor.getShapePageBounds(s.id)
if (bounds && bounds.x >= shapeBounds.x + shapeBounds.w) {
const shapeBottom = bounds.y + bounds.h
if (shapeBottom > maxY) {
maxY = shapeBottom + 20
}
}
})
startY = maxY === startY ? startY : maxY
}
const eventShape = CalendarEventShape.createFromEvent(
event,
startX,
startY,
CalendarShape.PRIMARY_COLOR
)
this.editor.createShapes([eventShape])
setTimeout(() => {
const newShapeBounds = this.editor.getShapePageBounds(eventShape.id as any)
if (newShapeBounds && shapeBounds) {
const combinedBounds = Box.Common([shapeBounds, newShapeBounds])
this.editor.zoomToBounds(combinedBounds, {
inset: 50,
animation: { duration: 400, easing: (t) => t * (2 - t) },
})
}
this.editor.setSelectedShapes([eventShape.id as any])
this.editor.setCurrentTool("select")
}, 50)
} catch (error) {
console.error("Error creating calendar event shape:", error)
}
}
// Handle month selection from year view
const handleMonthSelect = (year: number, month: number) => {
this.editor.updateShape<ICalendar>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
currentView: "browser",
calendarView: "month",
currentDate: new Date(year, month, 1).getTime(),
w: VIEW_SIZES.browser.w,
h: VIEW_SIZES.browser.h,
},
})
}
if (!isOpen) {
return null
}
// Render based on current view
const renderContent = () => {
switch (shape.props.currentView) {
case "widget":
return (
<CalendarWidgetContent
onEventSelect={handleEventSelect}
primaryColor={CalendarShape.PRIMARY_COLOR}
/>
)
case "year":
return (
<YearViewPanel
onClose={handleClose}
onMonthSelect={handleMonthSelect}
shapeMode={true}
initialYear={new Date(shape.props.currentDate).getFullYear()}
/>
)
case "browser":
default:
return (
<CalendarPanel
onClose={handleClose}
onEventSelect={handleEventSelect}
shapeMode={true}
initialView={shape.props.calendarView}
initialDate={new Date(shape.props.currentDate)}
/>
)
}
}
// View tabs component
const ViewTabs = () => {
const isDarkMode =
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
const tabs: { id: CalendarView; label: string; icon: string }[] = [
{ id: "browser", label: "Calendar", icon: "📅" },
{ id: "widget", label: "Widget", icon: "📋" },
{ id: "year", label: "Year", icon: "📆" },
]
return (
<div
style={{
display: "flex",
gap: "4px",
padding: "8px 12px",
borderBottom: `1px solid ${isDarkMode ? "#404040" : "#e5e7eb"}`,
backgroundColor: isDarkMode ? "#1a1a1a" : "#f9fafb",
}}
>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleViewChange(tab.id)}
onPointerDown={(e) => e.stopPropagation()}
style={{
display: "flex",
alignItems: "center",
gap: "4px",
padding: "6px 12px",
border: "none",
borderRadius: "6px",
fontSize: "12px",
fontWeight: shape.props.currentView === tab.id ? "600" : "400",
cursor: "pointer",
backgroundColor:
shape.props.currentView === tab.id
? CalendarShape.PRIMARY_COLOR
: isDarkMode
? "#374151"
: "#e5e7eb",
color:
shape.props.currentView === tab.id
? "#ffffff"
: isDarkMode
? "#e4e4e7"
: "#374151",
transition: "all 0.15s ease",
}}
>
<span>{tab.icon}</span>
<span>{tab.label}</span>
</button>
))}
</div>
)
}
return (
<HTMLContainer style={{ width: w, height: h }}>
<StandardizedToolWrapper
title="Calendar"
primaryColor={CalendarShape.PRIMARY_COLOR}
isSelected={isSelected}
width={w}
height={h}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
onMaximize={toggleMaximize}
isMaximized={isMaximized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<ICalendar>({
id: shape.id,
type: "Calendar",
props: {
...shape.props,
tags: newTags,
},
})
}}
tagsEditable={true}
>
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
}}
>
<ViewTabs />
<div style={{ flex: 1, overflow: "hidden" }}>{renderContent()}</div>
</div>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
indicator(shape: ICalendar) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
// Compact widget content component (extracted from CalendarWidgetShapeUtil)
const CalendarWidgetContent: React.FC<{
onEventSelect: (event: DecryptedCalendarEvent) => void
primaryColor: string
}> = ({ onEventSelect, primaryColor }) => {
const [currentDate, setCurrentDate] = useState(new Date())
const { events: upcomingEvents, loading, getEventsForDate } = useUpcomingEvents(5)
const isDarkMode =
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
const colors = isDarkMode
? {
bg: "#1f2937",
text: "#e4e4e7",
textMuted: "#a1a1aa",
border: "#404040",
todayBg: "#22c55e30",
eventDot: "#3b82f6",
cardBg: "#252525",
}
: {
bg: "#f9fafb",
text: "#1f2937",
textMuted: "#6b7280",
border: "#e5e7eb",
todayBg: "#22c55e20",
eventDot: "#3b82f6",
cardBg: "#f9fafb",
}
// Generate mini calendar
const getDaysInMonth = (year: number, month: number) =>
new Date(year, month + 1, 0).getDate()
const getFirstDayOfMonth = (year: number, month: number) => {
const day = new Date(year, month, 1).getDay()
return day === 0 ? 6 : day - 1
}
const isSameDay = (date1: Date, date2: Date) =>
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
const year = currentDate.getFullYear()
const month = currentDate.getMonth()
const daysInMonth = getDaysInMonth(year, month)
const firstDay = getFirstDayOfMonth(year, month)
const today = new Date()
const days: { day: number | null; date: Date | null }[] = []
for (let i = 0; i < firstDay; i++) {
days.push({ day: null, date: null })
}
for (let i = 1; i <= daysInMonth; i++) {
days.push({ day: i, date: new Date(year, month, i) })
}
const goToPrevMonth = () =>
setCurrentDate(new Date(year, month - 1, 1))
const goToNextMonth = () =>
setCurrentDate(new Date(year, month + 1, 1))
const formatEventTime = (event: DecryptedCalendarEvent) => {
if (event.isAllDay) return "All day"
return event.startTime.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
}
const formatEventDate = (event: DecryptedCalendarEvent) => {
if (isSameDay(today, event.startTime)) return "Today"
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
if (isSameDay(tomorrow, event.startTime)) return "Tomorrow"
return event.startTime.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})
}
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: colors.bg,
overflow: "hidden",
}}
>
{/* Mini Calendar Header */}
<div
style={{
padding: "8px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: `1px solid ${colors.border}`,
}}
>
<button
onClick={goToPrevMonth}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: isDarkMode ? "#374151" : "#e5e7eb",
border: "none",
color: colors.text,
width: "24px",
height: "24px",
borderRadius: "4px",
cursor: "pointer",
fontSize: "12px",
}}
>
&lt;
</button>
<span style={{ fontSize: "13px", fontWeight: "600", color: colors.text }}>
{currentDate.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
</span>
<button
onClick={goToNextMonth}
onPointerDown={(e) => e.stopPropagation()}
style={{
background: isDarkMode ? "#374151" : "#e5e7eb",
border: "none",
color: colors.text,
width: "24px",
height: "24px",
borderRadius: "4px",
cursor: "pointer",
fontSize: "12px",
}}
>
&gt;
</button>
</div>
{/* Mini Calendar Grid */}
<div style={{ padding: "8px" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "2px",
marginBottom: "4px",
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((day, i) => (
<div
key={i}
style={{
textAlign: "center",
fontSize: "10px",
fontWeight: "600",
color: colors.textMuted,
padding: "2px 0",
}}
>
{day}
</div>
))}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "2px",
}}
>
{days.map(({ day, date }, i) => {
const isToday = date && isSameDay(date, today)
const hasEvents = date && getEventsForDate(date).length > 0
return (
<div
key={i}
style={{
textAlign: "center",
fontSize: "11px",
padding: "4px 2px",
borderRadius: "4px",
backgroundColor: isToday ? colors.todayBg : "transparent",
border: isToday ? `1px solid ${primaryColor}` : "1px solid transparent",
color: day ? colors.text : "transparent",
fontWeight: isToday ? "700" : "400",
position: "relative",
}}
>
{day}
{hasEvents && (
<div
style={{
position: "absolute",
bottom: "1px",
left: "50%",
transform: "translateX(-50%)",
width: "4px",
height: "4px",
borderRadius: "50%",
backgroundColor: colors.eventDot,
}}
/>
)}
</div>
)
})}
</div>
</div>
{/* Upcoming Events */}
<div style={{ flex: 1, padding: "8px", overflow: "auto" }}>
<div
style={{
fontSize: "10px",
fontWeight: "600",
color: colors.textMuted,
textTransform: "uppercase",
letterSpacing: "0.5px",
marginBottom: "8px",
}}
>
Upcoming
</div>
{loading ? (
<div style={{ textAlign: "center", color: colors.textMuted, fontSize: "11px", padding: "12px 0" }}>
Loading...
</div>
) : upcomingEvents.length === 0 ? (
<div style={{ textAlign: "center", color: colors.textMuted, fontSize: "11px", padding: "12px 0" }}>
No upcoming events
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{upcomingEvents.map((event) => (
<div
key={event.id}
onClick={() => onEventSelect(event)}
onPointerDown={(e) => e.stopPropagation()}
style={{
padding: "6px 8px",
backgroundColor: colors.cardBg,
borderRadius: "6px",
borderLeft: `3px solid ${colors.eventDot}`,
cursor: "pointer",
}}
>
<div
style={{
fontSize: "11px",
fontWeight: "600",
color: colors.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{event.summary}
</div>
<div
style={{
fontSize: "10px",
color: colors.textMuted,
display: "flex",
gap: "4px",
}}
>
<span>{formatEventDate(event)}</span>
<span>|</span>
<span>{formatEventTime(event)}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}

140
src/tools/CalendarTool.ts Normal file
View File

@ -0,0 +1,140 @@
// Unified Calendar Tool - Places Calendar shape on canvas
// Supports switching between Browser, Widget, and Year views
import { StateNode } from "tldraw"
export class CalendarTool extends StateNode {
static override id = "calendar"
static override initial = "idle"
static override children = () => [CalendarIdle]
}
export class CalendarIdle extends StateNode {
static override id = "idle"
tooltipElement?: HTMLDivElement
mouseMoveHandler?: (e: MouseEvent) => void
isCreatingShape = false
override onEnter = () => {
// Set cursor to cross
this.editor.setCursor({ type: "cross", rotation: 0 })
// Create tooltip element
this.tooltipElement = document.createElement("div")
this.tooltipElement.textContent = "Click anywhere to place Calendar"
this.tooltipElement.style.cssText = `
position: fixed;
background: rgba(34, 197, 94, 0.95);
color: white;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
pointer-events: none;
z-index: 10000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
white-space: nowrap;
`
document.body.appendChild(this.tooltipElement)
// Update tooltip position on mouse move
this.mouseMoveHandler = (e: MouseEvent) => {
if (this.tooltipElement) {
this.tooltipElement.style.left = `${e.clientX + 15}px`
this.tooltipElement.style.top = `${e.clientY - 35}px`
}
}
document.addEventListener("mousemove", this.mouseMoveHandler)
}
override onPointerDown = (info?: any) => {
// Validate click event
if (!info?.point || info.button === undefined) return
if (info.button !== 0) return // Only left mouse button
// Prevent UI element clicks from creating shapes
if (info.target?.closest?.("[data-tldraw-ui]")) return
// Prevent double-creation
if (this.isCreatingShape) return
// Convert screen coords to page coords
const pagePoint = this.editor.screenToPage(info.point)
this.createCalendarShape(pagePoint.x, pagePoint.y)
}
private createCalendarShape(clickX: number, clickY: number) {
this.isCreatingShape = true
try {
// Check if a Calendar already exists - focus it instead of creating new one
const existingCalendar = this.editor
.getCurrentPageShapes()
.find((s) => s.type === "Calendar")
if (existingCalendar) {
// Focus existing calendar
this.editor.setSelectedShapes([existingCalendar.id])
const bounds = this.editor.getShapePageBounds(existingCalendar.id)
if (bounds) {
this.editor.zoomToBounds(bounds, {
inset: 50,
animation: { duration: 300, easing: (t) => t * (2 - t) },
})
}
this.editor.setCurrentTool("select")
setTimeout(() => {
this.isCreatingShape = false
}, 200)
return
}
// Default to browser view size
const shapeWidth = 900
const shapeHeight = 650
// Center shape on click
const baseX = clickX - shapeWidth / 2
const baseY = clickY - shapeHeight / 2
const calendarShape = this.editor.createShape({
type: "Calendar",
x: baseX,
y: baseY,
props: {
w: shapeWidth,
h: shapeHeight,
currentView: "browser",
},
})
// Switch back to select tool
this.editor.setCurrentTool("select")
// Reset flag after tool switch
setTimeout(() => {
this.isCreatingShape = false
}, 200)
} catch (error) {
console.error("Error creating Calendar shape:", error)
this.isCreatingShape = false
}
}
override onExit = () => {
// Clean up tooltip and listeners
if (this.mouseMoveHandler) {
document.removeEventListener("mousemove", this.mouseMoveHandler)
this.mouseMoveHandler = undefined
}
if (this.tooltipElement?.parentNode) {
document.body.removeChild(this.tooltipElement)
this.tooltipElement = undefined
}
}
// Cancel on escape
override onCancel = () => {
this.editor.setCurrentTool("select")
}
}

View File

@ -14,6 +14,7 @@ import { createShapeId } from "tldraw"
import type { ObsidianObsNote } from "../lib/obsidianImporter"
import { HolonData } from "../lib/HoloSphereService"
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
import { WorkflowPalette } from "../components/workflow/WorkflowPalette"
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService"
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from "../lib/networking/types"
@ -57,6 +58,7 @@ export function CustomToolbar() {
const [showHolonBrowser, setShowHolonBrowser] = useState(false)
const [vaultBrowserMode, setVaultBrowserMode] = useState<'keyboard' | 'button'>('keyboard')
const [showFathomPanel, setShowFathomPanel] = useState(false)
const [showWorkflowPalette, setShowWorkflowPalette] = useState(false)
const profilePopupRef = useRef<HTMLDivElement>(null)
const [isDarkMode, setIsDarkMode] = useState(getDarkMode())
@ -782,6 +784,21 @@ export function CustomToolbar() {
isSelected={tools["Map"].id === editor.getCurrentToolId()}
/>
)}
{tools["calendar"] && (
<TldrawUiMenuItem
{...tools["calendar"]}
icon="calendar"
label="Calendar"
isSelected={tools["calendar"].id === editor.getCurrentToolId()}
/>
)}
{/* Workflow Builder - Toggle Palette */}
<TldrawUiMenuItem
id="workflow-palette"
icon="sticker"
label="Workflow Blocks"
onSelect={() => setShowWorkflowPalette(!showWorkflowPalette)}
/>
{/* Refresh All ObsNotes Button */}
{(() => {
const allShapes = editor.getCurrentPageShapes()
@ -808,6 +825,12 @@ export function CustomToolbar() {
onClose={() => setShowFathomPanel(false)}
/>
)}
{/* Workflow Builder Palette */}
<WorkflowPalette
isOpen={showWorkflowPalette}
onClose={() => setShowWorkflowPalette(false)}
/>
</>
)
}

View File

@ -246,6 +246,14 @@ export const overrides: TLUiOverrides = {
readonlyOk: true,
onSelect: () => editor.setCurrentTool("map"),
},
calendar: {
id: "calendar",
icon: "calendar",
label: "Calendar",
kbd: "ctrl+alt+k",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("calendar"),
},
// MycelialIntelligence removed - now a permanent UI bar (MycelialIntelligenceBar.tsx)
hand: {
...tools.hand,