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:
Jeff Emmett 2026-02-25 15:27:58 -08:00
parent 5641dba450
commit cd7f4adf5e
16 changed files with 1527 additions and 210 deletions

View File

@ -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)} />

View File

@ -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
}

View File

@ -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)}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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) => (

View File

@ -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>

View File

@ -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">&rarr;</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>

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 &quot;Fullscreen&quot; for year-at-a-glance
</p>
</div>
</div>
)

View File

@ -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'

View File

@ -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 />
}

View File

@ -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>
)
}

420
src/lib/demo-data.ts Normal file
View File

@ -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
})
}

View File

@ -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