feat: add Day/Week views, view switcher, demo data with map transit lines
- Add DayView (24h time grid with events) and WeekView (7-day columns) - Add ViewSwitcher icon bar in header for quick Day/Week/Month/Season/Year/Decade switching - Fix zoom to properly route each granularity to its dedicated view - Normalize all views to consistent h-full flex-col container pattern - Change YearView default from fullscreen portal to inline compact/glance - Add FullscreenToggle wrapper for opt-in fullscreen on any view - Add ~40 demo events with real coordinates (Berlin, Amsterdam, Munich, Paris, Prague, Lisbon, Barcelona, Brussels) - Add DemoDataSeeder to pre-populate React Query cache on demo page - Add TransitLines component drawing dashed polylines between event locations on the map - Extend EventListItem type with latitude/longitude/coordinates fields - Add CSS fade transition between view switches Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5641dba450
commit
cd7f4adf5e
|
|
@ -13,6 +13,7 @@ import { ContextTab } from '@/components/tabs/ContextTab'
|
|||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { TemporalGranularity, TEMPORAL_GRANULARITY_LABELS, GRANULARITY_LABELS } from '@/lib/types'
|
||||
import type { TabView } from '@/lib/types'
|
||||
import { DemoDataSeeder } from '@/components/DemoDataSeeder'
|
||||
|
||||
export default function DemoPage() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
|
|
@ -69,6 +70,9 @@ export default function DemoPage() {
|
|||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Seed demo data into React Query cache */}
|
||||
<DemoDataSeeder />
|
||||
|
||||
{/* Sidebar */}
|
||||
{sidebarOpen && (
|
||||
<CalendarSidebar onClose={() => setSidebarOpen(false)} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { generateDemoEvents, getDemoEventsForRange, DEMO_SOURCES } from '@/lib/demo-data'
|
||||
import type { EventsResponse, SourcesResponse } from '@/lib/api'
|
||||
|
||||
/**
|
||||
* Pre-populates the React Query cache with demo data.
|
||||
* Place this inside the demo page (must be within QueryClientProvider).
|
||||
* Seeds events for current month +/- 3 months so all views have data.
|
||||
*/
|
||||
export function DemoDataSeeder() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
const now = new Date()
|
||||
const allEvents = generateDemoEvents()
|
||||
|
||||
// Seed month-level queries for -2 to +3 months
|
||||
for (let offset = -2; offset <= 3; offset++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + offset, 1)
|
||||
const year = d.getFullYear()
|
||||
const month = d.getMonth() + 1
|
||||
const start = new Date(year, month - 1, 1).toISOString().split('T')[0]
|
||||
const end = new Date(year, month, 0).toISOString().split('T')[0]
|
||||
|
||||
const monthEvents = getDemoEventsForRange(start, end)
|
||||
|
||||
const response: EventsResponse = {
|
||||
count: monthEvents.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: monthEvents,
|
||||
}
|
||||
|
||||
// Match the queryKey used by useMonthEvents
|
||||
queryClient.setQueryData(['events', 'month', year, month], response)
|
||||
}
|
||||
|
||||
// Seed the generic events query (used by SpatialTab's useEvents)
|
||||
// Seed for common date range patterns
|
||||
const seedGenericRange = (start: string, end: string) => {
|
||||
const rangeEvents = getDemoEventsForRange(start, end)
|
||||
const response: EventsResponse = {
|
||||
count: rangeEvents.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: rangeEvents,
|
||||
}
|
||||
queryClient.setQueryData(['events', { start, end }], response)
|
||||
}
|
||||
|
||||
// Current month range
|
||||
const curStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
|
||||
const curEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().split('T')[0]
|
||||
seedGenericRange(curStart, curEnd)
|
||||
|
||||
// Current week
|
||||
const weekStart = new Date(now)
|
||||
weekStart.setDate(now.getDate() - now.getDay())
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekStart.getDate() + 7)
|
||||
seedGenericRange(weekStart.toISOString().split('T')[0], weekEnd.toISOString().split('T')[0])
|
||||
|
||||
// Today
|
||||
const today = now.toISOString().split('T')[0]
|
||||
seedGenericRange(today, today)
|
||||
|
||||
// Current quarter
|
||||
const qMonth = Math.floor(now.getMonth() / 3) * 3
|
||||
const qStart = new Date(now.getFullYear(), qMonth, 1).toISOString().split('T')[0]
|
||||
const qEnd = new Date(now.getFullYear(), qMonth + 3, 0).toISOString().split('T')[0]
|
||||
seedGenericRange(qStart, qEnd)
|
||||
|
||||
// Current year
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1).toISOString().split('T')[0]
|
||||
const yearEnd = new Date(now.getFullYear(), 11, 31).toISOString().split('T')[0]
|
||||
seedGenericRange(yearStart, yearEnd)
|
||||
|
||||
// Seed sources
|
||||
const sourcesResponse: SourcesResponse = {
|
||||
count: DEMO_SOURCES.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: DEMO_SOURCES.map((s) => ({
|
||||
...s,
|
||||
event_count: allEvents.filter((e) => e.source === s.id).length,
|
||||
})),
|
||||
}
|
||||
queryClient.setQueryData(['sources'], sourcesResponse)
|
||||
}, [queryClient])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useCalendarStore } from '@/lib/store'
|
|||
import { TemporalGranularity, TEMPORAL_GRANULARITY_LABELS } from '@/lib/types'
|
||||
import { clsx } from 'clsx'
|
||||
import { AppSwitcher } from '@/components/AppSwitcher'
|
||||
import { ViewSwitcher } from './ViewSwitcher'
|
||||
|
||||
interface CalendarHeaderProps {
|
||||
onToggleSidebar: () => void
|
||||
|
|
@ -42,6 +43,11 @@ export function CalendarHeader({ onToggleSidebar, sidebarOpen }: CalendarHeaderP
|
|||
weekEnd.setDate(weekStart.getDate() + 6)
|
||||
return `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} -- ${weekEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
|
||||
}
|
||||
case TemporalGranularity.SEASON: {
|
||||
const quarter = Math.ceil((currentDate.getMonth() + 1) / 3)
|
||||
const seasonNames = ['Winter', 'Spring', 'Summer', 'Fall']
|
||||
return `${seasonNames[quarter - 1]} ${currentDate.getFullYear()}`
|
||||
}
|
||||
case TemporalGranularity.DAY:
|
||||
return currentDate.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
|
|
@ -132,8 +138,10 @@ export function CalendarHeader({ onToggleSidebar, sidebarOpen }: CalendarHeaderP
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right section - settings */}
|
||||
{/* View preset switcher */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ViewSwitcher />
|
||||
|
||||
{/* Show lunar overlay toggle */}
|
||||
<button
|
||||
onClick={() => setShowLunarOverlay(!showLunarOverlay)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { getSemanticLocationLabel } from '@/lib/location'
|
||||
import { useMonthEvents } from '@/hooks/useEvents'
|
||||
import type { EventListItem } from '@/lib/types'
|
||||
import { EventDetailModal } from './EventDetailModal'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
function formatHour(hour: number): string {
|
||||
if (hour === 0) return '12 AM'
|
||||
if (hour < 12) return `${hour} AM`
|
||||
if (hour === 12) return '12 PM'
|
||||
return `${hour - 12} PM`
|
||||
}
|
||||
|
||||
function getEventPosition(event: EventListItem, dayStart: Date) {
|
||||
const start = new Date(event.start)
|
||||
const end = new Date(event.end)
|
||||
const dayEnd = new Date(dayStart)
|
||||
dayEnd.setHours(23, 59, 59, 999)
|
||||
|
||||
// Clamp to day boundaries
|
||||
const effectiveStart = start < dayStart ? dayStart : start
|
||||
const effectiveEnd = end > dayEnd ? dayEnd : end
|
||||
|
||||
const startMinutes = effectiveStart.getHours() * 60 + effectiveStart.getMinutes()
|
||||
const endMinutes = effectiveEnd.getHours() * 60 + effectiveEnd.getMinutes()
|
||||
const duration = Math.max(endMinutes - startMinutes, 30) // Minimum 30 min display
|
||||
|
||||
return {
|
||||
top: (startMinutes / (24 * 60)) * 100,
|
||||
height: (duration / (24 * 60)) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
export function DayView() {
|
||||
const { currentDate, selectedEventId, setSelectedEventId, hiddenSources } = useCalendarStore()
|
||||
const effectiveSpatial = useEffectiveSpatialGranularity()
|
||||
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth() + 1
|
||||
|
||||
const { data: eventsData, isLoading } = useMonthEvents(year, month)
|
||||
|
||||
const dateKey = useMemo(() => {
|
||||
return currentDate.toISOString().split('T')[0]
|
||||
}, [currentDate])
|
||||
|
||||
const dayStart = useMemo(() => {
|
||||
const d = new Date(currentDate)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}, [currentDate])
|
||||
|
||||
const { allDayEvents, timedEvents } = useMemo(() => {
|
||||
if (!eventsData?.results) return { allDayEvents: [], timedEvents: [] }
|
||||
|
||||
const dayEvents = eventsData.results.filter((event) => {
|
||||
if (hiddenSources.includes(event.source)) return false
|
||||
const eventDate = new Date(event.start).toISOString().split('T')[0]
|
||||
const eventEnd = new Date(event.end).toISOString().split('T')[0]
|
||||
return eventDate <= dateKey && eventEnd >= dateKey
|
||||
})
|
||||
|
||||
return {
|
||||
allDayEvents: dayEvents.filter((e) => e.all_day),
|
||||
timedEvents: dayEvents.filter((e) => !e.all_day),
|
||||
}
|
||||
}, [eventsData?.results, dateKey, hiddenSources])
|
||||
|
||||
const isToday = useMemo(() => {
|
||||
const today = new Date()
|
||||
return (
|
||||
currentDate.getFullYear() === today.getFullYear() &&
|
||||
currentDate.getMonth() === today.getMonth() &&
|
||||
currentDate.getDate() === today.getDate()
|
||||
)
|
||||
}, [currentDate])
|
||||
|
||||
const nowPosition = useMemo(() => {
|
||||
if (!isToday) return null
|
||||
const now = new Date()
|
||||
const minutes = now.getHours() * 60 + now.getMinutes()
|
||||
return (minutes / (24 * 60)) * 100
|
||||
}, [isToday])
|
||||
|
||||
const dayOfWeek = currentDate.toLocaleDateString('en-US', { weekday: 'long' })
|
||||
const dateDisplay = currentDate.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col h-full">
|
||||
{/* Day header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{dayOfWeek}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{dateDisplay}</p>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{allDayEvents.length + timedEvents.length} events
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All-day events */}
|
||||
{allDayEvents.length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">All Day</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allDayEvents.map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => setSelectedEventId(event.id)}
|
||||
className="text-xs px-2 py-1 rounded truncate max-w-[200px] hover:opacity-80 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: event.source_color || '#3b82f6',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time grid */}
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
<div className="relative" style={{ minHeight: '1440px' }}>
|
||||
{/* Hour lines */}
|
||||
{HOURS.map((hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="absolute w-full flex border-b border-gray-100 dark:border-gray-700/50"
|
||||
style={{ top: `${(hour / 24) * 100}%`, height: `${(1 / 24) * 100}%` }}
|
||||
>
|
||||
<div className="w-16 flex-shrink-0 text-xs text-gray-400 dark:text-gray-500 text-right pr-2 pt-0.5">
|
||||
{formatHour(hour)}
|
||||
</div>
|
||||
<div className="flex-1 border-l border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Now indicator */}
|
||||
{nowPosition !== null && (
|
||||
<div
|
||||
className="absolute left-16 right-0 z-20 flex items-center"
|
||||
style={{ top: `${nowPosition}%` }}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 -ml-1" />
|
||||
<div className="flex-1 h-px bg-red-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timed events */}
|
||||
<div className="absolute left-16 right-2 top-0 bottom-0">
|
||||
{timedEvents.map((event) => {
|
||||
const pos = getEventPosition(event, dayStart)
|
||||
const locationLabel = getSemanticLocationLabel(event, effectiveSpatial)
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => setSelectedEventId(event.id)}
|
||||
className="absolute left-1 right-1 rounded px-2 py-1 text-left overflow-hidden hover:opacity-90 transition-opacity z-10"
|
||||
style={{
|
||||
top: `${pos.top}%`,
|
||||
height: `${pos.height}%`,
|
||||
minHeight: '20px',
|
||||
backgroundColor: event.source_color || '#3b82f6',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-medium truncate">{event.title}</div>
|
||||
<div className="text-[10px] opacity-75 truncate">
|
||||
{new Date(event.start).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{' - '}
|
||||
{new Date(event.end).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{locationLabel && ` \u00B7 ${locationLabel}`}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && (
|
||||
<div className="absolute left-16 right-2 top-[25%] z-10">
|
||||
<div className="h-16 bg-gray-200 dark:bg-gray-600 rounded animate-pulse mx-1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event detail modal */}
|
||||
{selectedEventId && (
|
||||
<EventDetailModal
|
||||
eventId={selectedEventId}
|
||||
onClose={() => setSelectedEventId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Maximize2, Minimize2 } from 'lucide-react'
|
||||
|
||||
interface FullscreenToggleProps {
|
||||
children: (isFullscreen: boolean) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a fullscreen toggle for any calendar view.
|
||||
* Renders a small button that toggles between inline and portal-based fullscreen.
|
||||
* Esc exits fullscreen.
|
||||
*/
|
||||
export function FullscreenToggle({ children }: FullscreenToggleProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isFullscreen) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsFullscreen(false)
|
||||
}
|
||||
},
|
||||
[isFullscreen]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isFullscreen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
}, [isFullscreen, handleKeyDown])
|
||||
|
||||
if (!mounted) return <>{children(false)}</>
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<>
|
||||
{/* Placeholder in normal flow */}
|
||||
<div className="h-full flex items-center justify-center bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-gray-400 dark:text-gray-500 text-sm">
|
||||
Fullscreen active — press Esc to return
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen portal */}
|
||||
{createPortal(
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-gray-900">
|
||||
{/* Close bar */}
|
||||
<div className="flex items-center justify-end px-4 py-1.5 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Exit fullscreen (Esc)"
|
||||
>
|
||||
<Minimize2 className="w-3.5 h-3.5" />
|
||||
Exit Fullscreen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content fills remaining space */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{children(true)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children(false)}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Small button to toggle fullscreen. Place in a view's header.
|
||||
*/
|
||||
export function FullscreenButton({
|
||||
isFullscreen,
|
||||
onToggle,
|
||||
}: {
|
||||
isFullscreen: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1.5 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title={isFullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen (F)'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
) : (
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -42,9 +42,9 @@ export function MonthView() {
|
|||
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 h-full flex flex-col">
|
||||
{/* Month header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{new Date(monthData.year, monthData.month - 1).toLocaleDateString('en-US', {
|
||||
|
|
@ -67,7 +67,7 @@ export function MonthView() {
|
|||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="p-2">
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 gap-px mb-1">
|
||||
{weekdays.map((day, i) => (
|
||||
|
|
|
|||
|
|
@ -120,9 +120,9 @@ export function SeasonView() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 h-full flex flex-col">
|
||||
{/* Season header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white text-center">
|
||||
{seasonName} {year}
|
||||
</h2>
|
||||
|
|
@ -133,8 +133,8 @@ export function SeasonView() {
|
|||
</div>
|
||||
|
||||
{/* Three month grid */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="flex gap-4 h-full">
|
||||
{quarterMonths.map((month) => (
|
||||
<MonthGrid
|
||||
key={month}
|
||||
|
|
@ -147,7 +147,7 @@ export function SeasonView() {
|
|||
</div>
|
||||
|
||||
{/* Navigation hint */}
|
||||
<div className="px-4 pb-4">
|
||||
<div className="px-4 pb-3 pt-1 flex-shrink-0">
|
||||
<p className="text-xs text-center text-gray-400 dark:text-gray-500">
|
||||
Click any day to zoom in • Use arrow keys to navigate quarters
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useEffect, useRef, useMemo } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents } from 'react-leaflet'
|
||||
import { MapContainer, TileLayer, CircleMarker, Polyline, Popup, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
||||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { useMapState } from '@/hooks/useMapState'
|
||||
import { getSemanticLocationLabel } from '@/lib/location'
|
||||
import { SpatialGranularity, SPATIAL_TO_LEAFLET_ZOOM, GRANULARITY_LABELS, leafletZoomToSpatial } from '@/lib/types'
|
||||
import type { EventListItem, UnifiedEvent } from '@/lib/types'
|
||||
import type { EventListItem } from '@/lib/types'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
// Fix Leaflet default marker icons for Next.js bundling
|
||||
|
|
@ -62,18 +62,12 @@ function EventMarkers({ events }: { events: EventListItem[] }) {
|
|||
const spatialGranularity = useEffectiveSpatialGranularity()
|
||||
const isBroadZoom = spatialGranularity <= SpatialGranularity.COUNTRY
|
||||
|
||||
// Group events with coordinates by semantic location at broad zooms
|
||||
const markers = useMemo(() => {
|
||||
// Filter events that have coordinate-like data embedded in location_raw
|
||||
// (EventListItem doesn't have lat/lng directly, so we pass through all events
|
||||
// and rely on the full UnifiedEvent type if available)
|
||||
const eventsWithCoords = events.filter((e) => {
|
||||
const ev = e as EventListItem & { latitude?: number | null; longitude?: number | null }
|
||||
return ev.latitude != null && ev.longitude != null
|
||||
}) as (EventListItem & { latitude: number; longitude: number })[]
|
||||
const eventsWithCoords = events.filter(
|
||||
(e) => e.latitude != null && e.longitude != null
|
||||
) as (EventListItem & { latitude: number; longitude: number })[]
|
||||
|
||||
if (!isBroadZoom) {
|
||||
// Fine zoom: individual markers
|
||||
return eventsWithCoords.map((e) => ({
|
||||
key: e.id,
|
||||
lat: e.latitude,
|
||||
|
|
@ -165,6 +159,83 @@ function EventMarkers({ events }: { events: EventListItem[] }) {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws dashed polylines between chronologically ordered events that have
|
||||
* different locations, representing transit/travel between them.
|
||||
*/
|
||||
function TransitLines({ events }: { events: EventListItem[] }) {
|
||||
const spatialGranularity = useEffectiveSpatialGranularity()
|
||||
|
||||
const segments = useMemo(() => {
|
||||
// Sort events chronologically, only those with coordinates
|
||||
const sorted = events
|
||||
.filter((e) => e.latitude != null && e.longitude != null)
|
||||
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()) as (EventListItem & { latitude: number; longitude: number })[]
|
||||
|
||||
if (sorted.length < 2) return []
|
||||
|
||||
const lines: {
|
||||
key: string
|
||||
from: [number, number]
|
||||
to: [number, number]
|
||||
fromLabel: string
|
||||
toLabel: string
|
||||
color: string
|
||||
isTravel: boolean
|
||||
}[] = []
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const curr = sorted[i]
|
||||
const next = sorted[i + 1]
|
||||
|
||||
// Skip if same location (within ~1km)
|
||||
const latDiff = Math.abs(curr.latitude - next.latitude)
|
||||
const lngDiff = Math.abs(curr.longitude - next.longitude)
|
||||
if (latDiff < 0.01 && lngDiff < 0.01) continue
|
||||
|
||||
// Check if this is a travel/transit event (by source)
|
||||
const isTravel = curr.source === 'travel' || next.source === 'travel'
|
||||
|
||||
lines.push({
|
||||
key: `${curr.id}-${next.id}`,
|
||||
from: [curr.latitude, curr.longitude],
|
||||
to: [next.latitude, next.longitude],
|
||||
fromLabel: curr.location_raw || curr.title,
|
||||
toLabel: next.location_raw || next.title,
|
||||
color: isTravel ? '#f97316' : '#94a3b8',
|
||||
isTravel,
|
||||
})
|
||||
}
|
||||
|
||||
return lines
|
||||
}, [events])
|
||||
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg) => (
|
||||
<Polyline
|
||||
key={seg.key}
|
||||
positions={[seg.from, seg.to]}
|
||||
pathOptions={{
|
||||
color: seg.color,
|
||||
weight: seg.isTravel ? 3 : 2,
|
||||
opacity: seg.isTravel ? 0.8 : 0.4,
|
||||
dashArray: seg.isTravel ? '8, 8' : '4, 8',
|
||||
}}
|
||||
>
|
||||
<Tooltip sticky>
|
||||
<div className="text-xs">
|
||||
<span className="font-medium">{seg.fromLabel}</span>
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
<span className="font-medium">{seg.toLabel}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Polyline>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpatioTemporalMap({ events }: SpatioTemporalMapProps) {
|
||||
const { center, zoom } = useMapState(events)
|
||||
const spatialGranularity = useEffectiveSpatialGranularity()
|
||||
|
|
@ -183,6 +254,7 @@ export function SpatioTemporalMap({ events }: SpatioTemporalMapProps) {
|
|||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<TransitLines events={events} />
|
||||
<EventMarkers events={events} />
|
||||
</MapContainer>
|
||||
|
||||
|
|
@ -203,7 +275,7 @@ export function SpatioTemporalMap({ events }: SpatioTemporalMapProps) {
|
|||
)}
|
||||
title={zoomCoupled ? 'Unlink spatial from temporal zoom' : 'Link spatial to temporal zoom'}
|
||||
>
|
||||
{zoomCoupled ? '🔗' : '🔓'}
|
||||
{zoomCoupled ? '\uD83D\uDD17' : '\uD83D\uDD13'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
'use client'
|
||||
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import { TemporalGranularity, TEMPORAL_GRANULARITY_LABELS } from '@/lib/types'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface ViewPreset {
|
||||
granularity: TemporalGranularity
|
||||
label: string
|
||||
shortLabel: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const VIEW_PRESETS: ViewPreset[] = [
|
||||
{
|
||||
granularity: TemporalGranularity.DAY,
|
||||
label: 'Day view',
|
||||
shortLabel: 'Day',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
<path d="M8 2v4M16 2v4" />
|
||||
<rect x="7" y="14" width="4" height="4" rx="0.5" fill="currentColor" opacity="0.3" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
granularity: TemporalGranularity.WEEK,
|
||||
label: 'Week view',
|
||||
shortLabel: 'Week',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
<path d="M8 2v4M16 2v4" />
|
||||
<path d="M7 14h10" strokeOpacity="0.5" />
|
||||
<path d="M7 17h10" strokeOpacity="0.5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
granularity: TemporalGranularity.MONTH,
|
||||
label: 'Month view',
|
||||
shortLabel: 'Month',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
<path d="M8 2v4M16 2v4" />
|
||||
<path d="M7 14h2M11 14h2M15 14h2" strokeWidth="1.5" />
|
||||
<path d="M7 17h2M11 17h2M15 17h2" strokeWidth="1.5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
granularity: TemporalGranularity.SEASON,
|
||||
label: 'Season view (quarter)',
|
||||
shortLabel: 'Season',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="4" width="6" height="18" rx="1" />
|
||||
<rect x="9" y="4" width="6" height="18" rx="1" />
|
||||
<rect x="16" y="4" width="6" height="18" rx="1" />
|
||||
<path d="M2 9h6M9 9h6M16 9h6" strokeWidth="1" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
granularity: TemporalGranularity.YEAR,
|
||||
label: 'Year view',
|
||||
shortLabel: 'Year',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="2" width="9" height="9" rx="1" />
|
||||
<rect x="13" y="2" width="9" height="9" rx="1" />
|
||||
<rect x="2" y="13" width="9" height="9" rx="1" />
|
||||
<rect x="13" y="13" width="9" height="9" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
granularity: TemporalGranularity.DECADE,
|
||||
label: 'Decade view',
|
||||
shortLabel: 'Decade',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="1" y="1" width="5" height="5" rx="0.5" />
|
||||
<rect x="7" y="1" width="5" height="5" rx="0.5" />
|
||||
<rect x="13" y="1" width="5" height="5" rx="0.5" />
|
||||
<rect x="19" y="1" width="4" height="5" rx="0.5" />
|
||||
<rect x="1" y="7.5" width="5" height="5" rx="0.5" />
|
||||
<rect x="7" y="7.5" width="5" height="5" rx="0.5" />
|
||||
<rect x="13" y="7.5" width="5" height="5" rx="0.5" />
|
||||
<rect x="19" y="7.5" width="4" height="5" rx="0.5" />
|
||||
<rect x="1" y="14" width="5" height="5" rx="0.5" />
|
||||
<rect x="7" y="14" width="5" height="5" rx="0.5" />
|
||||
<rect x="13" y="14" width="5" height="5" rx="0.5" />
|
||||
<rect x="19" y="14" width="4" height="5" rx="0.5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
interface ViewSwitcherProps {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function ViewSwitcher({ compact = false }: ViewSwitcherProps) {
|
||||
const { temporalGranularity, setTemporalGranularity } = useCalendarStore()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
|
||||
{VIEW_PRESETS.map((preset) => {
|
||||
const isActive = temporalGranularity === preset.granularity
|
||||
return (
|
||||
<button
|
||||
key={preset.granularity}
|
||||
onClick={() => setTemporalGranularity(preset.granularity)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-2 py-1.5 rounded-md text-xs font-medium transition-all duration-150',
|
||||
isActive
|
||||
? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600/50'
|
||||
)}
|
||||
title={`${preset.label} (${TEMPORAL_GRANULARITY_LABELS[preset.granularity]})`}
|
||||
>
|
||||
{preset.icon}
|
||||
{!compact && <span className="hidden sm:inline">{preset.shortLabel}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { getSemanticLocationLabel } from '@/lib/location'
|
||||
import { useMonthEvents } from '@/hooks/useEvents'
|
||||
import { TemporalGranularity } from '@/lib/types'
|
||||
import type { EventListItem } from '@/lib/types'
|
||||
import { EventDetailModal } from './EventDetailModal'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
function formatHour(hour: number): string {
|
||||
if (hour === 0) return '12 AM'
|
||||
if (hour < 12) return `${hour} AM`
|
||||
if (hour === 12) return '12 PM'
|
||||
return `${hour - 12} PM`
|
||||
}
|
||||
|
||||
function getWeekDays(date: Date): Date[] {
|
||||
const start = new Date(date)
|
||||
start.setDate(date.getDate() - date.getDay()) // Start on Sunday
|
||||
start.setHours(0, 0, 0, 0)
|
||||
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(start)
|
||||
d.setDate(start.getDate() + i)
|
||||
return d
|
||||
})
|
||||
}
|
||||
|
||||
function getEventPosition(event: EventListItem, dayStart: Date) {
|
||||
const start = new Date(event.start)
|
||||
const end = new Date(event.end)
|
||||
const dayEnd = new Date(dayStart)
|
||||
dayEnd.setHours(23, 59, 59, 999)
|
||||
|
||||
const effectiveStart = start < dayStart ? dayStart : start
|
||||
const effectiveEnd = end > dayEnd ? dayEnd : end
|
||||
|
||||
const startMinutes = effectiveStart.getHours() * 60 + effectiveStart.getMinutes()
|
||||
const endMinutes = effectiveEnd.getHours() * 60 + effectiveEnd.getMinutes()
|
||||
const duration = Math.max(endMinutes - startMinutes, 30)
|
||||
|
||||
return {
|
||||
top: (startMinutes / (24 * 60)) * 100,
|
||||
height: (duration / (24 * 60)) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
export function WeekView() {
|
||||
const {
|
||||
currentDate,
|
||||
selectedEventId,
|
||||
setSelectedEventId,
|
||||
setCurrentDate,
|
||||
setTemporalGranularity,
|
||||
hiddenSources,
|
||||
} = useCalendarStore()
|
||||
const effectiveSpatial = useEffectiveSpatialGranularity()
|
||||
|
||||
const weekDays = useMemo(() => getWeekDays(currentDate), [currentDate])
|
||||
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth() + 1
|
||||
|
||||
// We may need events from adjacent months if the week spans months
|
||||
const { data: eventsData, isLoading } = useMonthEvents(year, month)
|
||||
|
||||
const today = useMemo(() => {
|
||||
const d = new Date()
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}, [])
|
||||
|
||||
const nowPosition = useMemo(() => {
|
||||
const now = new Date()
|
||||
const minutes = now.getHours() * 60 + now.getMinutes()
|
||||
return (minutes / (24 * 60)) * 100
|
||||
}, [])
|
||||
|
||||
// Group events by day key
|
||||
const eventsByDay = useMemo(() => {
|
||||
const map = new Map<string, { allDay: EventListItem[]; timed: EventListItem[] }>()
|
||||
if (!eventsData?.results) return map
|
||||
|
||||
for (const event of eventsData.results) {
|
||||
if (hiddenSources.includes(event.source)) continue
|
||||
|
||||
for (const day of weekDays) {
|
||||
const dayKey = day.toISOString().split('T')[0]
|
||||
const eventStart = new Date(event.start).toISOString().split('T')[0]
|
||||
const eventEnd = new Date(event.end).toISOString().split('T')[0]
|
||||
|
||||
if (eventStart <= dayKey && eventEnd >= dayKey) {
|
||||
if (!map.has(dayKey)) map.set(dayKey, { allDay: [], timed: [] })
|
||||
const bucket = map.get(dayKey)!
|
||||
if (event.all_day) {
|
||||
if (!bucket.allDay.find((e) => e.id === event.id)) bucket.allDay.push(event)
|
||||
} else {
|
||||
if (!bucket.timed.find((e) => e.id === event.id)) bucket.timed.push(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [eventsData?.results, weekDays, hiddenSources])
|
||||
|
||||
const hasAllDayEvents = useMemo(
|
||||
() => Array.from(eventsByDay.values()).some((d) => d.allDay.length > 0),
|
||||
[eventsByDay]
|
||||
)
|
||||
|
||||
const handleDayClick = (date: Date) => {
|
||||
setCurrentDate(date)
|
||||
setTemporalGranularity(TemporalGranularity.DAY)
|
||||
}
|
||||
|
||||
const weekRange = `${weekDays[0].toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} \u2013 ${weekDays[6].toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col h-full">
|
||||
{/* Week header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{weekRange}</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Week {Math.ceil((currentDate.getDate() + new Date(year, month - 1, 1).getDay()) / 7)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
{/* Spacer for time column */}
|
||||
<div className="w-16 flex-shrink-0" />
|
||||
|
||||
{weekDays.map((day) => {
|
||||
const isToday = day.getTime() === today.getTime()
|
||||
const dayKey = day.toISOString().split('T')[0]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dayKey}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={clsx(
|
||||
'flex-1 py-2 text-center border-l border-gray-200 dark:border-gray-700 transition-colors',
|
||||
'hover:bg-gray-50 dark:hover:bg-gray-700/50',
|
||||
isToday && 'bg-blue-50 dark:bg-blue-900/20'
|
||||
)}
|
||||
>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{day.toLocaleDateString('en-US', { weekday: 'short' })}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'text-lg font-semibold',
|
||||
isToday
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
)}
|
||||
>
|
||||
{day.getDate()}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* All-day events row */}
|
||||
{hasAllDayEvents && (
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="w-16 flex-shrink-0 text-xs text-gray-400 text-right pr-2 py-1">
|
||||
All day
|
||||
</div>
|
||||
{weekDays.map((day) => {
|
||||
const dayKey = day.toISOString().split('T')[0]
|
||||
const dayEvents = eventsByDay.get(dayKey)
|
||||
return (
|
||||
<div
|
||||
key={`allday-${dayKey}`}
|
||||
className="flex-1 border-l border-gray-200 dark:border-gray-700 p-0.5 min-h-[28px]"
|
||||
>
|
||||
{dayEvents?.allDay.map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => setSelectedEventId(event.id)}
|
||||
className="block w-full text-[10px] px-1 py-0.5 rounded truncate hover:opacity-80 transition-opacity mb-0.5"
|
||||
style={{
|
||||
backgroundColor: event.source_color || '#3b82f6',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time grid */}
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
<div className="relative" style={{ minHeight: '1440px' }}>
|
||||
{/* Hour lines */}
|
||||
{HOURS.map((hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="absolute w-full flex border-b border-gray-100 dark:border-gray-700/50"
|
||||
style={{ top: `${(hour / 24) * 100}%`, height: `${(1 / 24) * 100}%` }}
|
||||
>
|
||||
<div className="w-16 flex-shrink-0 text-xs text-gray-400 dark:text-gray-500 text-right pr-2 pt-0.5">
|
||||
{formatHour(hour)}
|
||||
</div>
|
||||
<div className="flex-1 flex">
|
||||
{weekDays.map((day) => (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className="flex-1 border-l border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Now indicator */}
|
||||
{weekDays.some((d) => d.getTime() === today.getTime()) && (
|
||||
<div
|
||||
className="absolute left-16 right-0 z-20 flex items-center pointer-events-none"
|
||||
style={{ top: `${nowPosition}%` }}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 -ml-1" />
|
||||
<div className="flex-1 h-px bg-red-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events overlay */}
|
||||
<div className="absolute left-16 right-0 top-0 bottom-0 flex">
|
||||
{weekDays.map((day, colIdx) => {
|
||||
const dayKey = day.toISOString().split('T')[0]
|
||||
const dayEvents = eventsByDay.get(dayKey)
|
||||
const dayStart = new Date(day)
|
||||
dayStart.setHours(0, 0, 0, 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dayKey}
|
||||
className="flex-1 relative border-l border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{dayEvents?.timed.map((event) => {
|
||||
const pos = getEventPosition(event, dayStart)
|
||||
const locationLabel = getSemanticLocationLabel(event, effectiveSpatial)
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => setSelectedEventId(event.id)}
|
||||
className="absolute left-0.5 right-0.5 rounded px-1 py-0.5 text-left overflow-hidden hover:opacity-90 transition-opacity z-10"
|
||||
style={{
|
||||
top: `${pos.top}%`,
|
||||
height: `${pos.height}%`,
|
||||
minHeight: '18px',
|
||||
backgroundColor: event.source_color || '#3b82f6',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] font-medium truncate">{event.title}</div>
|
||||
<div className="text-[9px] opacity-75 truncate">
|
||||
{new Date(event.start).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="absolute left-16 right-0 top-[25%] z-10 flex gap-1 px-1">
|
||||
{weekDays.map((_, i) => (
|
||||
<div key={i} className="flex-1 h-12 bg-gray-200 dark:bg-gray-600 rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event detail modal */}
|
||||
{selectedEventId && (
|
||||
<EventDetailModal
|
||||
eventId={selectedEventId}
|
||||
onClose={() => setSelectedEventId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import { TemporalGranularity } from '@/lib/types'
|
||||
import { clsx } from 'clsx'
|
||||
|
|
@ -137,7 +136,7 @@ function toMondayFirst(dayOfWeek: number): number {
|
|||
// Weekday labels starting with Monday
|
||||
const WEEKDAY_LABELS_MONDAY_FIRST = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
|
||||
// Glance-style vertical month column - FULLSCREEN version with weekday alignment
|
||||
// Glance-style vertical month column (inline, not fullscreen)
|
||||
interface GlanceMonthColumnProps {
|
||||
year: number
|
||||
month: number
|
||||
|
|
@ -163,7 +162,6 @@ function GlanceMonthColumn({ year, month, onDayClick }: GlanceMonthColumnProps)
|
|||
const calendarGrid = useMemo(() => {
|
||||
const slots: CalendarSlot[] = []
|
||||
|
||||
// Gregorian: Variable days, variable start day
|
||||
const firstDay = new Date(year, month - 1, 1)
|
||||
const lastDay = new Date(year, month, 0)
|
||||
const daysInMonth = lastDay.getDate()
|
||||
|
|
@ -199,13 +197,13 @@ function GlanceMonthColumn({ year, month, onDayClick }: GlanceMonthColumnProps)
|
|||
const monthColor = MONTH_COLORS[month - 1]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-w-[7rem]">
|
||||
<div className="flex flex-col flex-1 min-w-[5.5rem]">
|
||||
{/* Month header */}
|
||||
<div
|
||||
className="text-center py-2 font-bold text-white flex-shrink-0"
|
||||
className="text-center py-1.5 font-bold text-white flex-shrink-0 text-sm"
|
||||
style={{ backgroundColor: monthColor }}
|
||||
>
|
||||
<div className="text-base">{monthName}</div>
|
||||
{monthName}
|
||||
</div>
|
||||
|
||||
{/* Days grid - aligned by weekday */}
|
||||
|
|
@ -214,7 +212,7 @@ function GlanceMonthColumn({ year, month, onDayClick }: GlanceMonthColumnProps)
|
|||
style={{ borderColor: `${monthColor}40` }}
|
||||
>
|
||||
{calendarGrid.map((slot, idx) => {
|
||||
const isWeekend = slot.dayOfWeek >= 5 // Saturday=5, Sunday=6
|
||||
const isWeekend = slot.dayOfWeek >= 5
|
||||
const isSunday = slot.dayOfWeek === 6
|
||||
const isMonday = slot.dayOfWeek === 0
|
||||
|
||||
|
|
@ -223,7 +221,7 @@ function GlanceMonthColumn({ year, month, onDayClick }: GlanceMonthColumnProps)
|
|||
<div
|
||||
key={`empty-${idx}`}
|
||||
className={clsx(
|
||||
'flex-1 min-h-[1.25rem] w-full flex items-center gap-2 px-2 border-b last:border-b-0',
|
||||
'flex-1 min-h-[1rem] w-full flex items-center gap-1 px-1 border-b last:border-b-0',
|
||||
isSunday && 'bg-red-50/50 dark:bg-red-900/10',
|
||||
isWeekend && !isSunday && 'bg-blue-50/50 dark:bg-blue-900/10',
|
||||
!isWeekend && 'bg-gray-50 dark:bg-gray-800/50',
|
||||
|
|
@ -231,7 +229,7 @@ function GlanceMonthColumn({ year, month, onDayClick }: GlanceMonthColumnProps)
|
|||
)}
|
||||
style={{ borderColor: `${monthColor}20` }}
|
||||
>
|
||||
<span className="text-[10px] font-medium w-5 flex-shrink-0 text-gray-300 dark:text-gray-600">
|
||||
<span className="text-[9px] font-medium w-4 flex-shrink-0 text-gray-300 dark:text-gray-600">
|
||||
{WEEKDAY_LABELS_MONDAY_FIRST[slot.dayOfWeek]}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -243,7 +241,7 @@ function GlanceMonthColumn({ year, month, onDayClick }: GlanceMonthColumnProps)
|
|||
key={slot.day}
|
||||
onClick={() => onDayClick?.(slot.date)}
|
||||
className={clsx(
|
||||
'flex-1 min-h-[1.25rem] w-full flex items-center gap-2 px-2 transition-all',
|
||||
'flex-1 min-h-[1rem] w-full flex items-center gap-1 px-1 transition-all',
|
||||
'hover:brightness-95 border-b last:border-b-0',
|
||||
slot.isToday && 'ring-2 ring-inset font-bold',
|
||||
isSunday && !slot.isToday && 'bg-red-50 dark:bg-red-900/20',
|
||||
|
|
@ -261,7 +259,7 @@ function GlanceMonthColumn({ year, month, onDayClick }: GlanceMonthColumnProps)
|
|||
}}
|
||||
>
|
||||
<span className={clsx(
|
||||
'text-[10px] font-medium w-5 flex-shrink-0',
|
||||
'text-[9px] font-medium w-4 flex-shrink-0',
|
||||
isSunday && !slot.isToday && 'text-red-500',
|
||||
isWeekend && !isSunday && !slot.isToday && 'text-blue-500',
|
||||
!isWeekend && !slot.isToday && 'text-gray-400 dark:text-gray-500'
|
||||
|
|
@ -269,15 +267,11 @@ function GlanceMonthColumn({ year, month, onDayClick }: GlanceMonthColumnProps)
|
|||
{WEEKDAY_LABELS_MONDAY_FIRST[slot.dayOfWeek]}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'text-sm font-semibold w-5 flex-shrink-0',
|
||||
'text-xs font-semibold w-4 flex-shrink-0',
|
||||
!slot.isToday && 'text-gray-800 dark:text-gray-200'
|
||||
)}>
|
||||
{slot.day}
|
||||
</span>
|
||||
{/* Space for events/location */}
|
||||
<span className="flex-1 text-xs text-gray-500 dark:text-gray-400 truncate text-left">
|
||||
{/* Future: event/location info here */}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
|
@ -286,125 +280,13 @@ function GlanceMonthColumn({ year, month, onDayClick }: GlanceMonthColumnProps)
|
|||
)
|
||||
}
|
||||
|
||||
// Fullscreen Glance View Portal
|
||||
interface FullscreenGlanceProps {
|
||||
year: number
|
||||
onDayClick: (date: Date) => void
|
||||
onClose: () => void
|
||||
navigatePrev: () => void
|
||||
navigateNext: () => void
|
||||
}
|
||||
|
||||
function FullscreenGlance({
|
||||
year,
|
||||
onDayClick,
|
||||
onClose,
|
||||
navigatePrev,
|
||||
navigateNext,
|
||||
}: FullscreenGlanceProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
|
||||
// Handle escape key
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
navigatePrev()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
navigateNext()
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent body scroll
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose, navigatePrev, navigateNext])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
const content = (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-gradient-to-br from-slate-100 via-blue-50 to-purple-50 dark:from-gray-900 dark:via-slate-900 dark:to-gray-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-3 flex-shrink-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={navigatePrev}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title="Previous year"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
{year}
|
||||
</h1>
|
||||
<button
|
||||
onClick={navigateNext}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title="Next year"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 bg-white/50 dark:bg-gray-800/50 px-3 py-1 rounded-full">
|
||||
Gregorian Calendar
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-sm"
|
||||
title="Exit fullscreen (Esc)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Exit Fullscreen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Month columns - takes up remaining space */}
|
||||
<div className="flex-1 flex gap-1 p-3 overflow-x-auto min-h-0">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<GlanceMonthColumn
|
||||
key={i + 1}
|
||||
year={year}
|
||||
month={i + 1}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-xs text-gray-500 dark:text-gray-400 py-2 bg-white/50 dark:bg-gray-900/50 backdrop-blur flex-shrink-0">
|
||||
Click any day to zoom in • Arrow keys navigate years • Esc to exit • T for today
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
|
||||
export function YearView() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('glance')
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('compact')
|
||||
const {
|
||||
currentDate,
|
||||
setCurrentDate,
|
||||
setViewType,
|
||||
setTemporalGranularity,
|
||||
navigateByGranularity,
|
||||
} = useCalendarStore()
|
||||
|
||||
const year = currentDate.getFullYear()
|
||||
|
|
@ -421,12 +303,10 @@ export function YearView() {
|
|||
setTemporalGranularity(TemporalGranularity.DAY)
|
||||
}
|
||||
|
||||
const months = 12
|
||||
|
||||
// Mock event counts for compact view
|
||||
const mockEventCounts = useMemo(() => {
|
||||
const counts: Record<number, Record<number, number>> = {}
|
||||
for (let m = 1; m <= months; m++) {
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
counts[m] = {}
|
||||
for (let d = 1; d <= 28; d++) {
|
||||
if (Math.random() > 0.7) {
|
||||
|
|
@ -435,37 +315,56 @@ export function YearView() {
|
|||
}
|
||||
}
|
||||
return counts
|
||||
}, [months])
|
||||
}, [])
|
||||
|
||||
// Glance mode uses fullscreen portal
|
||||
// Glance mode - inline (fills container, no portal)
|
||||
if (viewMode === 'glance') {
|
||||
return (
|
||||
<>
|
||||
{/* Placeholder in normal flow */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="text-lg font-medium mb-2">Glance View Active</div>
|
||||
<div className="text-sm">Fullscreen calendar is displayed. Press Esc to return.</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{year}</h2>
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode('compact')}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-md transition-colors text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('glance')}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-md transition-colors bg-white dark:bg-gray-600 shadow-sm"
|
||||
>
|
||||
Glance
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen portal */}
|
||||
<FullscreenGlance
|
||||
year={year}
|
||||
onDayClick={handleDayClick}
|
||||
onClose={() => setViewMode('compact')}
|
||||
navigatePrev={() => navigateByGranularity('prev')}
|
||||
navigateNext={() => navigateByGranularity('next')}
|
||||
/>
|
||||
</>
|
||||
{/* Month columns - fills remaining space */}
|
||||
<div className="flex-1 flex gap-0.5 p-2 overflow-x-auto min-h-0">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<GlanceMonthColumn
|
||||
key={i + 1}
|
||||
year={year}
|
||||
month={i + 1}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-xs text-gray-400 dark:text-gray-500 py-1.5 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
Click any day to zoom in
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact view - traditional grid layout
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 h-full flex flex-col">
|
||||
{/* Year header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{year}
|
||||
|
|
@ -476,26 +375,26 @@ export function YearView() {
|
|||
</div>
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode('compact')}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white dark:bg-gray-600 shadow-sm"
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-md transition-colors bg-white dark:bg-gray-600 shadow-sm"
|
||||
>
|
||||
Compact
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('glance')}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md transition-colors text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-md transition-colors text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
Fullscreen
|
||||
Glance
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Month grid */}
|
||||
<div className="p-4">
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="grid gap-3 grid-cols-4 md:grid-cols-6">
|
||||
{Array.from({ length: months }).map((_, i) => {
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const month = i + 1
|
||||
const isCurrentMonthView = currentMonth === month && currentDate.getFullYear() === year
|
||||
|
||||
|
|
@ -511,23 +410,23 @@ export function YearView() {
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex items-center justify-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-blue-500" />
|
||||
<span>Today</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/20" />
|
||||
<span>Current month</span>
|
||||
{/* Legend */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-blue-500" />
|
||||
<span>Today</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/20" />
|
||||
<span>Current month</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-center text-gray-400 dark:text-gray-500 mt-2">
|
||||
Click any month to zoom in
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-center text-gray-400 dark:text-gray-500 mt-2">
|
||||
Click any month to zoom in • Click "Fullscreen" for year-at-a-glance
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
export { DayView } from './DayView'
|
||||
export { WeekView } from './WeekView'
|
||||
export { MonthView } from './MonthView'
|
||||
export { SeasonView } from './SeasonView'
|
||||
export { YearView } from './YearView'
|
||||
export { CalendarHeader } from './CalendarHeader'
|
||||
export { CalendarSidebar } from './CalendarSidebar'
|
||||
export { TemporalZoomController } from './TemporalZoomController'
|
||||
export { ViewSwitcher } from './ViewSwitcher'
|
||||
export { FullscreenToggle, FullscreenButton } from './FullscreenToggle'
|
||||
export { SplitView } from './SplitView'
|
||||
export { SpatioTemporalMap } from './SpatioTemporalMapLoader'
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { useCalendarStore } from '@/lib/store'
|
|||
import { useEvents } from '@/hooks/useEvents'
|
||||
import { TemporalGranularity } from '@/lib/types'
|
||||
import { SplitView } from '@/components/calendar/SplitView'
|
||||
import { DayView } from '@/components/calendar/DayView'
|
||||
import { WeekView } from '@/components/calendar/WeekView'
|
||||
import { MonthView } from '@/components/calendar/MonthView'
|
||||
import { SeasonView } from '@/components/calendar/SeasonView'
|
||||
import { YearView } from '@/components/calendar/YearView'
|
||||
|
|
@ -58,12 +60,19 @@ function CalendarView() {
|
|||
switch (temporalGranularity) {
|
||||
case TemporalGranularity.YEAR:
|
||||
case TemporalGranularity.DECADE:
|
||||
case TemporalGranularity.CENTURY:
|
||||
case TemporalGranularity.COSMIC:
|
||||
return <YearView />
|
||||
case TemporalGranularity.SEASON:
|
||||
return <SeasonView />
|
||||
case TemporalGranularity.MONTH:
|
||||
return <MonthView />
|
||||
case TemporalGranularity.WEEK:
|
||||
return <WeekView />
|
||||
case TemporalGranularity.DAY:
|
||||
case TemporalGranularity.HOUR:
|
||||
case TemporalGranularity.MOMENT:
|
||||
return <DayView />
|
||||
default:
|
||||
return <MonthView />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,65 @@
|
|||
'use client'
|
||||
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import { TemporalGranularity } from '@/lib/types'
|
||||
import { DayView } from '@/components/calendar/DayView'
|
||||
import { WeekView } from '@/components/calendar/WeekView'
|
||||
import { MonthView } from '@/components/calendar/MonthView'
|
||||
import { SeasonView } from '@/components/calendar/SeasonView'
|
||||
import { YearView } from '@/components/calendar/YearView'
|
||||
import { FullscreenToggle } from '@/components/calendar/FullscreenToggle'
|
||||
|
||||
function getViewForGranularity(granularity: TemporalGranularity) {
|
||||
switch (granularity) {
|
||||
case TemporalGranularity.DAY:
|
||||
case TemporalGranularity.HOUR:
|
||||
case TemporalGranularity.MOMENT:
|
||||
return DayView
|
||||
case TemporalGranularity.WEEK:
|
||||
return WeekView
|
||||
case TemporalGranularity.MONTH:
|
||||
return MonthView
|
||||
case TemporalGranularity.SEASON:
|
||||
return SeasonView
|
||||
case TemporalGranularity.YEAR:
|
||||
case TemporalGranularity.DECADE:
|
||||
case TemporalGranularity.CENTURY:
|
||||
case TemporalGranularity.COSMIC:
|
||||
return YearView
|
||||
default:
|
||||
return MonthView
|
||||
}
|
||||
}
|
||||
|
||||
export function TemporalTab() {
|
||||
const { temporalGranularity } = useCalendarStore()
|
||||
const [transitioning, setTransitioning] = useState(false)
|
||||
const prevGranularity = useRef(temporalGranularity)
|
||||
|
||||
const CalendarView = () => {
|
||||
switch (temporalGranularity) {
|
||||
case TemporalGranularity.YEAR:
|
||||
case TemporalGranularity.DECADE:
|
||||
return <YearView />
|
||||
case TemporalGranularity.SEASON:
|
||||
return <SeasonView />
|
||||
case TemporalGranularity.MONTH:
|
||||
case TemporalGranularity.WEEK:
|
||||
case TemporalGranularity.DAY:
|
||||
default:
|
||||
return <MonthView />
|
||||
useEffect(() => {
|
||||
if (prevGranularity.current !== temporalGranularity) {
|
||||
setTransitioning(true)
|
||||
const timer = setTimeout(() => setTransitioning(false), 200)
|
||||
prevGranularity.current = temporalGranularity
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [temporalGranularity])
|
||||
|
||||
const ViewComponent = getViewForGranularity(temporalGranularity)
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-auto p-4">
|
||||
<CalendarView />
|
||||
</main>
|
||||
<FullscreenToggle>
|
||||
{(isFullscreen) => (
|
||||
<main
|
||||
className="h-full overflow-auto p-4 transition-opacity duration-200"
|
||||
style={{ opacity: transitioning ? 0 : 1 }}
|
||||
>
|
||||
<div className="h-full">
|
||||
<ViewComponent />
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
</FullscreenToggle>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,420 @@
|
|||
import type { EventListItem, CalendarSource } from './types'
|
||||
|
||||
// ── Demo Calendar Sources ──
|
||||
|
||||
export const DEMO_SOURCES: CalendarSource[] = [
|
||||
{
|
||||
id: 'work',
|
||||
name: 'Work',
|
||||
source_type: 'google',
|
||||
source_type_display: 'Google Calendar',
|
||||
color: '#3b82f6',
|
||||
is_visible: true,
|
||||
is_active: true,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
sync_error: '',
|
||||
event_count: 0,
|
||||
},
|
||||
{
|
||||
id: 'travel',
|
||||
name: 'Travel',
|
||||
source_type: 'manual',
|
||||
source_type_display: 'Manual',
|
||||
color: '#f97316',
|
||||
is_visible: true,
|
||||
is_active: true,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
sync_error: '',
|
||||
event_count: 0,
|
||||
},
|
||||
{
|
||||
id: 'personal',
|
||||
name: 'Personal',
|
||||
source_type: 'ics',
|
||||
source_type_display: 'ICS',
|
||||
color: '#10b981',
|
||||
is_visible: true,
|
||||
is_active: true,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
sync_error: '',
|
||||
event_count: 0,
|
||||
},
|
||||
{
|
||||
id: 'conferences',
|
||||
name: 'Conferences',
|
||||
source_type: 'manual',
|
||||
source_type_display: 'Manual',
|
||||
color: '#8b5cf6',
|
||||
is_visible: true,
|
||||
is_active: true,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
sync_error: '',
|
||||
event_count: 0,
|
||||
},
|
||||
]
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function makeId(): string {
|
||||
return `demo-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function isoDate(year: number, month: number, day: number, hour = 0, minute = 0): string {
|
||||
return new Date(year, month - 1, day, hour, minute).toISOString()
|
||||
}
|
||||
|
||||
// ── Generate demo events relative to current date ──
|
||||
|
||||
export function generateDemoEvents(): EventListItem[] {
|
||||
const now = new Date()
|
||||
const y = now.getFullYear()
|
||||
const m = now.getMonth() + 1 // 1-indexed
|
||||
|
||||
// Helper for months relative to current
|
||||
const rel = (monthOffset: number, day: number, hour = 0, minute = 0) => {
|
||||
const date = new Date(y, now.getMonth() + monthOffset, day, hour, minute)
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
const events: EventListItem[] = [
|
||||
// ─── THIS MONTH: Dense schedule ───
|
||||
|
||||
// Work meetings in Berlin
|
||||
{
|
||||
id: makeId(), title: 'Team Standup', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(0, 3, 9, 0), end: rel(0, 3, 9, 30), all_day: false,
|
||||
location_raw: 'Berlin, Germany', location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5200, longitude: 13.4050,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Product Review', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(0, 5, 14, 0), end: rel(0, 5, 15, 30), all_day: false,
|
||||
location_raw: 'Berlin, Germany', location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5200, longitude: 13.4050,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Sprint Planning', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(0, 7, 10, 0), end: rel(0, 7, 12, 0), all_day: false,
|
||||
location_raw: 'Berlin, Germany', location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5200, longitude: 13.4050,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Client Call - NYC', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(0, 8, 16, 0), end: rel(0, 8, 17, 0), all_day: false,
|
||||
location_raw: 'Virtual (Zoom)', location_breadcrumb: null,
|
||||
is_virtual: true, status: 'confirmed',
|
||||
latitude: null, longitude: null,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: '1:1 with Manager', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(0, 10, 11, 0), end: rel(0, 10, 11, 30), all_day: false,
|
||||
location_raw: 'Berlin, Germany', location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5200, longitude: 13.4050,
|
||||
},
|
||||
|
||||
// Travel: Berlin → Amsterdam
|
||||
{
|
||||
id: makeId(), title: 'Train to Amsterdam', source: 'travel', source_color: '#f97316',
|
||||
start: rel(0, 12, 7, 30), end: rel(0, 12, 13, 45), all_day: false,
|
||||
location_raw: 'Berlin Hbf → Amsterdam Centraal',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5251, longitude: 13.3694, // Berlin Hbf
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Arrive Amsterdam', source: 'travel', source_color: '#f97316',
|
||||
start: rel(0, 12, 13, 45), end: rel(0, 12, 14, 30), all_day: false,
|
||||
location_raw: 'Amsterdam Centraal', location_breadcrumb: 'Earth > Europe > Netherlands > Amsterdam',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.3791, longitude: 4.9003,
|
||||
},
|
||||
|
||||
// Amsterdam meetings
|
||||
{
|
||||
id: makeId(), title: 'Partner Meeting', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(0, 13, 10, 0), end: rel(0, 13, 12, 0), all_day: false,
|
||||
location_raw: 'Amsterdam, Netherlands',
|
||||
location_breadcrumb: 'Earth > Europe > Netherlands > Amsterdam',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.3676, longitude: 4.9041,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Canal District Walk', source: 'personal', source_color: '#10b981',
|
||||
start: rel(0, 13, 15, 0), end: rel(0, 13, 17, 0), all_day: false,
|
||||
location_raw: 'Jordaan, Amsterdam',
|
||||
location_breadcrumb: 'Earth > Europe > Netherlands > Amsterdam',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.3738, longitude: 4.8820,
|
||||
},
|
||||
|
||||
// Return to Berlin
|
||||
{
|
||||
id: makeId(), title: 'Train to Berlin', source: 'travel', source_color: '#f97316',
|
||||
start: rel(0, 14, 8, 0), end: rel(0, 14, 14, 15), all_day: false,
|
||||
location_raw: 'Amsterdam Centraal → Berlin Hbf',
|
||||
location_breadcrumb: 'Earth > Europe > Netherlands > Amsterdam',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.3791, longitude: 4.9003,
|
||||
},
|
||||
|
||||
// Personal events
|
||||
{
|
||||
id: makeId(), title: 'Dinner with Friends', source: 'personal', source_color: '#10b981',
|
||||
start: rel(0, 15, 19, 0), end: rel(0, 15, 22, 0), all_day: false,
|
||||
location_raw: 'Kreuzberg, Berlin',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.4934, longitude: 13.4032,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Weekend Hike', source: 'personal', source_color: '#10b981',
|
||||
start: rel(0, 17, 8, 0), end: rel(0, 17, 16, 0), all_day: false,
|
||||
location_raw: 'Grunewald, Berlin',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.4730, longitude: 13.2260,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Yoga Class', source: 'personal', source_color: '#10b981',
|
||||
start: rel(0, 20, 7, 0), end: rel(0, 20, 8, 0), all_day: false,
|
||||
location_raw: 'Prenzlauer Berg, Berlin',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5388, longitude: 13.4244,
|
||||
},
|
||||
|
||||
// Work wrap-up
|
||||
{
|
||||
id: makeId(), title: 'Sprint Retro', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(0, 21, 14, 0), end: rel(0, 21, 15, 0), all_day: false,
|
||||
location_raw: 'Berlin, Germany', location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5200, longitude: 13.4050,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Demo Day', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(0, 22, 15, 0), end: rel(0, 22, 16, 30), all_day: false,
|
||||
location_raw: 'Berlin, Germany', location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5200, longitude: 13.4050,
|
||||
},
|
||||
|
||||
// Conference at end of month
|
||||
{
|
||||
id: makeId(), title: 'Web Summit (Day 1)', source: 'conferences', source_color: '#8b5cf6',
|
||||
start: rel(0, 25, 9, 0), end: rel(0, 25, 18, 0), all_day: false,
|
||||
location_raw: 'Lisbon, Portugal',
|
||||
location_breadcrumb: 'Earth > Europe > Portugal > Lisbon',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 38.7223, longitude: -9.1393,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Web Summit (Day 2)', source: 'conferences', source_color: '#8b5cf6',
|
||||
start: rel(0, 26, 9, 0), end: rel(0, 26, 18, 0), all_day: false,
|
||||
location_raw: 'Lisbon, Portugal',
|
||||
location_breadcrumb: 'Earth > Europe > Portugal > Lisbon',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 38.7223, longitude: -9.1393,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Lisbon City Tour', source: 'personal', source_color: '#10b981',
|
||||
start: rel(0, 27, 10, 0), end: rel(0, 27, 17, 0), all_day: false,
|
||||
location_raw: 'Alfama, Lisbon',
|
||||
location_breadcrumb: 'Earth > Europe > Portugal > Lisbon',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 38.7139, longitude: -9.1300,
|
||||
},
|
||||
|
||||
// ─── LAST MONTH ───
|
||||
|
||||
{
|
||||
id: makeId(), title: 'Quarterly Planning', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(-1, 5, 9, 0), end: rel(-1, 5, 17, 0), all_day: false,
|
||||
location_raw: 'Berlin, Germany', location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5200, longitude: 13.4050,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Munich Trip', source: 'travel', source_color: '#f97316',
|
||||
start: rel(-1, 10, 6, 0), end: rel(-1, 10, 10, 30), all_day: false,
|
||||
location_raw: 'Berlin → Munich (Flight)',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5200, longitude: 13.4050,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Munich Office Visit', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(-1, 10, 13, 0), end: rel(-1, 10, 17, 0), all_day: false,
|
||||
location_raw: 'Munich, Germany',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Munich',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 48.1351, longitude: 11.5820,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Olympiapark Jog', source: 'personal', source_color: '#10b981',
|
||||
start: rel(-1, 11, 7, 0), end: rel(-1, 11, 8, 30), all_day: false,
|
||||
location_raw: 'Olympiapark, Munich',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Munich',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 48.1749, longitude: 11.5526,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Return to Berlin', source: 'travel', source_color: '#f97316',
|
||||
start: rel(-1, 12, 15, 0), end: rel(-1, 12, 16, 30), all_day: false,
|
||||
location_raw: 'Munich → Berlin (Flight)',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Munich',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 48.1351, longitude: 11.5820,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Birthday Party', source: 'personal', source_color: '#10b981',
|
||||
start: rel(-1, 18, 18, 0), end: rel(-1, 18, 23, 0), all_day: false,
|
||||
location_raw: 'Friedrichshain, Berlin',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5125, longitude: 13.4556,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Hackathon', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(-1, 22, 0, 0), end: rel(-1, 23, 23, 59), all_day: true,
|
||||
location_raw: 'c-base, Berlin',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5130, longitude: 13.4200,
|
||||
},
|
||||
|
||||
// ─── NEXT MONTH ───
|
||||
|
||||
// Trip to Paris
|
||||
{
|
||||
id: makeId(), title: 'Flight to Paris', source: 'travel', source_color: '#f97316',
|
||||
start: rel(1, 3, 8, 0), end: rel(1, 3, 10, 30), all_day: false,
|
||||
location_raw: 'BER → CDG',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.3667, longitude: 13.5033, // BER airport
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Paris Office Kickoff', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(1, 4, 9, 0), end: rel(1, 4, 17, 0), all_day: false,
|
||||
location_raw: 'Le Marais, Paris',
|
||||
location_breadcrumb: 'Earth > Europe > France > Paris',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 48.8566, longitude: 2.3522,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Louvre Visit', source: 'personal', source_color: '#10b981',
|
||||
start: rel(1, 5, 10, 0), end: rel(1, 5, 14, 0), all_day: false,
|
||||
location_raw: 'Louvre Museum, Paris',
|
||||
location_breadcrumb: 'Earth > Europe > France > Paris',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 48.8606, longitude: 2.3376,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Return to Berlin', source: 'travel', source_color: '#f97316',
|
||||
start: rel(1, 6, 18, 0), end: rel(1, 6, 20, 30), all_day: false,
|
||||
location_raw: 'CDG → BER',
|
||||
location_breadcrumb: 'Earth > Europe > France > Paris',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 49.0097, longitude: 2.5479, // CDG airport
|
||||
},
|
||||
|
||||
// Mid-month work
|
||||
{
|
||||
id: makeId(), title: 'Board Meeting', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(1, 12, 10, 0), end: rel(1, 12, 12, 0), all_day: false,
|
||||
location_raw: 'Berlin, Germany', location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5200, longitude: 13.4050,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Design Workshop', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(1, 15, 13, 0), end: rel(1, 15, 17, 0), all_day: false,
|
||||
location_raw: 'Berlin, Germany', location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5200, longitude: 13.4050,
|
||||
},
|
||||
|
||||
// Weekend trip to Prague
|
||||
{
|
||||
id: makeId(), title: 'Bus to Prague', source: 'travel', source_color: '#f97316',
|
||||
start: rel(1, 20, 7, 0), end: rel(1, 20, 11, 30), all_day: false,
|
||||
location_raw: 'Berlin ZOB → Prague',
|
||||
location_breadcrumb: 'Earth > Europe > Germany > Berlin',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 52.5074, longitude: 13.2790, // Berlin ZOB
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Prague Castle Visit', source: 'personal', source_color: '#10b981',
|
||||
start: rel(1, 21, 10, 0), end: rel(1, 21, 15, 0), all_day: false,
|
||||
location_raw: 'Prague Castle, Prague',
|
||||
location_breadcrumb: 'Earth > Europe > Czech Republic > Prague',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 50.0911, longitude: 14.4003,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Bus to Berlin', source: 'travel', source_color: '#f97316',
|
||||
start: rel(1, 22, 14, 0), end: rel(1, 22, 18, 30), all_day: false,
|
||||
location_raw: 'Prague → Berlin ZOB',
|
||||
location_breadcrumb: 'Earth > Europe > Czech Republic > Prague',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 50.0755, longitude: 14.4378,
|
||||
},
|
||||
|
||||
// ─── TWO MONTHS OUT ───
|
||||
|
||||
{
|
||||
id: makeId(), title: 'Annual Retreat', source: 'work', source_color: '#3b82f6',
|
||||
start: rel(2, 8, 0, 0), end: rel(2, 11, 23, 59), all_day: true,
|
||||
location_raw: 'Barcelona, Spain',
|
||||
location_breadcrumb: 'Earth > Europe > Spain > Barcelona',
|
||||
is_virtual: false, status: 'tentative',
|
||||
latitude: 41.3874, longitude: 2.1686,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'Sagrada Familia Tour', source: 'personal', source_color: '#10b981',
|
||||
start: rel(2, 9, 10, 0), end: rel(2, 9, 13, 0), all_day: false,
|
||||
location_raw: 'Sagrada Familia, Barcelona',
|
||||
location_breadcrumb: 'Earth > Europe > Spain > Barcelona',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 41.4036, longitude: 2.1744,
|
||||
},
|
||||
{
|
||||
id: makeId(), title: 'EthCC Conference', source: 'conferences', source_color: '#8b5cf6',
|
||||
start: rel(2, 18, 9, 0), end: rel(2, 20, 18, 0), all_day: true,
|
||||
location_raw: 'Brussels, Belgium',
|
||||
location_breadcrumb: 'Earth > Europe > Belgium > Brussels',
|
||||
is_virtual: false, status: 'confirmed',
|
||||
latitude: 50.8503, longitude: 4.3517,
|
||||
},
|
||||
]
|
||||
|
||||
// Assign coordinates object for events that have lat/lng
|
||||
return events.map((e) => ({
|
||||
...e,
|
||||
coordinates:
|
||||
e.latitude != null && e.longitude != null
|
||||
? { latitude: e.latitude, longitude: e.longitude }
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get demo events filtered to a date range.
|
||||
*/
|
||||
export function getDemoEventsForRange(start: string, end: string): EventListItem[] {
|
||||
const allEvents = generateDemoEvents()
|
||||
const startDate = new Date(start)
|
||||
const endDate = new Date(end)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
|
||||
return allEvents.filter((e) => {
|
||||
const eventStart = new Date(e.start)
|
||||
const eventEnd = new Date(e.end)
|
||||
return eventStart <= endDate && eventEnd >= startDate
|
||||
})
|
||||
}
|
||||
|
|
@ -245,9 +245,15 @@ export interface EventListItem {
|
|||
all_day: boolean
|
||||
source: string
|
||||
source_color: string
|
||||
source_type?: string
|
||||
location_raw: string
|
||||
location_breadcrumb?: string | null
|
||||
is_virtual: boolean
|
||||
status: string
|
||||
// Coordinates (returned by API, used by map)
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
coordinates?: { latitude: number; longitude: number } | null
|
||||
// Lunar data (computed client-side)
|
||||
lunarPhase?: LunarPhaseName
|
||||
lunarEmoji?: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue