diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index 28aab26..1352f8b 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -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 (
+ {/* Seed demo data into React Query cache */} + + {/* Sidebar */} {sidebarOpen && ( setSidebarOpen(false)} /> diff --git a/src/components/DemoDataSeeder.tsx b/src/components/DemoDataSeeder.tsx new file mode 100644 index 0000000..b502cc8 --- /dev/null +++ b/src/components/DemoDataSeeder.tsx @@ -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 +} diff --git a/src/components/calendar/CalendarHeader.tsx b/src/components/calendar/CalendarHeader.tsx index 7e8bcfc..ac435c4 100644 --- a/src/components/calendar/CalendarHeader.tsx +++ b/src/components/calendar/CalendarHeader.tsx @@ -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
- {/* Right section - settings */} + {/* View preset switcher */}
+ + {/* Show lunar overlay toggle */} + ))} +
+ + )} + + {/* Time grid */} +
+
+ {/* Hour lines */} + {HOURS.map((hour) => ( +
+
+ {formatHour(hour)} +
+
+
+ ))} + + {/* Now indicator */} + {nowPosition !== null && ( +
+
+
+
+ )} + + {/* Timed events */} +
+ {timedEvents.map((event) => { + const pos = getEventPosition(event, dayStart) + const locationLabel = getSemanticLocationLabel(event, effectiveSpatial) + return ( + + ) + })} +
+ + {/* Loading skeleton */} + {isLoading && ( +
+
+
+ )} +
+
+ + {/* Event detail modal */} + {selectedEventId && ( + setSelectedEventId(null)} + /> + )} +
+ ) +} diff --git a/src/components/calendar/FullscreenToggle.tsx b/src/components/calendar/FullscreenToggle.tsx new file mode 100644 index 0000000..0ec937d --- /dev/null +++ b/src/components/calendar/FullscreenToggle.tsx @@ -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 */} +
+
+ Fullscreen active — press Esc to return +
+
+ + {/* Fullscreen portal */} + {createPortal( +
+ {/* Close bar */} +
+ +
+ + {/* Content fills remaining space */} +
+ {children(true)} +
+
, + 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 ( + + ) +} diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx index c38ac2b..118b0a4 100644 --- a/src/components/calendar/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -42,9 +42,9 @@ export function MonthView() { const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] return ( -
+
{/* Month header */} -
+

{new Date(monthData.year, monthData.month - 1).toLocaleDateString('en-US', { @@ -67,7 +67,7 @@ export function MonthView() {

{/* Calendar grid */} -
+
{/* Weekday headers */}
{weekdays.map((day, i) => ( diff --git a/src/components/calendar/SeasonView.tsx b/src/components/calendar/SeasonView.tsx index 3e22318..101d899 100644 --- a/src/components/calendar/SeasonView.tsx +++ b/src/components/calendar/SeasonView.tsx @@ -120,9 +120,9 @@ export function SeasonView() { } return ( -
+
{/* Season header */} -
+

{seasonName} {year}

@@ -133,8 +133,8 @@ export function SeasonView() {
{/* Three month grid */} -
-
+
+
{quarterMonths.map((month) => ( {/* Navigation hint */} -
+

Click any day to zoom in • Use arrow keys to navigate quarters

diff --git a/src/components/calendar/SpatioTemporalMap.tsx b/src/components/calendar/SpatioTemporalMap.tsx index 1e70ea5..4c560da 100644 --- a/src/components/calendar/SpatioTemporalMap.tsx +++ b/src/components/calendar/SpatioTemporalMap.tsx @@ -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) => ( + + +
+ {seg.fromLabel} + + {seg.toLabel} +
+
+
+ ))} + + ) +} + 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" /> + @@ -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'}
diff --git a/src/components/calendar/ViewSwitcher.tsx b/src/components/calendar/ViewSwitcher.tsx new file mode 100644 index 0000000..c62ea31 --- /dev/null +++ b/src/components/calendar/ViewSwitcher.tsx @@ -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: ( + + + + + + + ), + }, + { + granularity: TemporalGranularity.WEEK, + label: 'Week view', + shortLabel: 'Week', + icon: ( + + + + + + + + ), + }, + { + granularity: TemporalGranularity.MONTH, + label: 'Month view', + shortLabel: 'Month', + icon: ( + + + + + + + + ), + }, + { + granularity: TemporalGranularity.SEASON, + label: 'Season view (quarter)', + shortLabel: 'Season', + icon: ( + + + + + + + ), + }, + { + granularity: TemporalGranularity.YEAR, + label: 'Year view', + shortLabel: 'Year', + icon: ( + + + + + + + ), + }, + { + granularity: TemporalGranularity.DECADE, + label: 'Decade view', + shortLabel: 'Decade', + icon: ( + + + + + + + + + + + + + + + ), + }, +] + +interface ViewSwitcherProps { + compact?: boolean +} + +export function ViewSwitcher({ compact = false }: ViewSwitcherProps) { + const { temporalGranularity, setTemporalGranularity } = useCalendarStore() + + return ( +
+ {VIEW_PRESETS.map((preset) => { + const isActive = temporalGranularity === preset.granularity + return ( + + ) + })} +
+ ) +} diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx new file mode 100644 index 0000000..7fa7b2b --- /dev/null +++ b/src/components/calendar/WeekView.tsx @@ -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() + 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 ( +
+ {/* Week header */} +
+
+

{weekRange}

+ + Week {Math.ceil((currentDate.getDate() + new Date(year, month - 1, 1).getDay()) / 7)} + +
+
+ + {/* Day headers */} +
+ {/* Spacer for time column */} +
+ + {weekDays.map((day) => { + const isToday = day.getTime() === today.getTime() + const dayKey = day.toISOString().split('T')[0] + + return ( + + ) + })} +
+ + {/* All-day events row */} + {hasAllDayEvents && ( +
+
+ All day +
+ {weekDays.map((day) => { + const dayKey = day.toISOString().split('T')[0] + const dayEvents = eventsByDay.get(dayKey) + return ( +
+ {dayEvents?.allDay.map((event) => ( + + ))} +
+ ) + })} +
+ )} + + {/* Time grid */} +
+
+ {/* Hour lines */} + {HOURS.map((hour) => ( +
+
+ {formatHour(hour)} +
+
+ {weekDays.map((day) => ( +
+ ))} +
+
+ ))} + + {/* Now indicator */} + {weekDays.some((d) => d.getTime() === today.getTime()) && ( +
+
+
+
+ )} + + {/* Events overlay */} +
+ {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 ( +
+ {dayEvents?.timed.map((event) => { + const pos = getEventPosition(event, dayStart) + const locationLabel = getSemanticLocationLabel(event, effectiveSpatial) + return ( + + ) + })} +
+ ) + })} +
+ + {/* Loading */} + {isLoading && ( +
+ {weekDays.map((_, i) => ( +
+ ))} +
+ )} +
+
+ + {/* Event detail modal */} + {selectedEventId && ( + setSelectedEventId(null)} + /> + )} +
+ ) +} diff --git a/src/components/calendar/YearView.tsx b/src/components/calendar/YearView.tsx index 679af74..119ea54 100644 --- a/src/components/calendar/YearView.tsx +++ b/src/components/calendar/YearView.tsx @@ -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 ( -
+
{/* Month header */}
-
{monthName}
+ {monthName}
{/* 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)
- + {WEEKDAY_LABELS_MONDAY_FIRST[slot.dayOfWeek]}
@@ -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) }} > {slot.day} - {/* Space for events/location */} - - {/* Future: event/location info here */} - ) })} @@ -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 = ( -
- {/* Header */} -
-
- -

- {year} -

- - - Gregorian Calendar - -
- - -
- - {/* Month columns - takes up remaining space */} -
- {Array.from({ length: 12 }).map((_, i) => ( - - ))} -
- - {/* Footer */} -
- Click any day to zoom in • Arrow keys navigate years • Esc to exit • T for today -
-
- ) - - return createPortal(content, document.body) -} - export function YearView() { - const [viewMode, setViewMode] = useState('glance') + const [viewMode, setViewMode] = useState('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> = {} - 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 */} -
-
-
Glance View Active
-
Fullscreen calendar is displayed. Press Esc to return.
+
+ {/* Header */} +
+

{year}

+
+ +
- {/* Fullscreen portal */} - setViewMode('compact')} - navigatePrev={() => navigateByGranularity('prev')} - navigateNext={() => navigateByGranularity('next')} - /> - + {/* Month columns - fills remaining space */} +
+ {Array.from({ length: 12 }).map((_, i) => ( + + ))} +
+ + {/* Footer */} +
+ Click any day to zoom in +
+
) } // Compact view - traditional grid layout return ( -
+
{/* Year header */} -
+

{year} @@ -476,26 +375,26 @@ export function YearView() {

{/* View mode toggle */} -
+
{/* Month grid */} -
+
- {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() { ) })}
-
- {/* Legend */} -
-
-
-
- Today -
-
-
- Current month + {/* Legend */} +
+
+
+
+ Today +
+
+
+ Current month +
+

+ Click any month to zoom in +

-

- Click any month to zoom in • Click "Fullscreen" for year-at-a-glance -

) diff --git a/src/components/calendar/index.ts b/src/components/calendar/index.ts index 3015370..bbcae30 100644 --- a/src/components/calendar/index.ts +++ b/src/components/calendar/index.ts @@ -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' diff --git a/src/components/tabs/SpatialTab.tsx b/src/components/tabs/SpatialTab.tsx index 2f3d985..e818a3d 100644 --- a/src/components/tabs/SpatialTab.tsx +++ b/src/components/tabs/SpatialTab.tsx @@ -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 case TemporalGranularity.SEASON: return case TemporalGranularity.MONTH: + return case TemporalGranularity.WEEK: + return case TemporalGranularity.DAY: + case TemporalGranularity.HOUR: + case TemporalGranularity.MOMENT: + return default: return } diff --git a/src/components/tabs/TemporalTab.tsx b/src/components/tabs/TemporalTab.tsx index 109b663..5c51ebd 100644 --- a/src/components/tabs/TemporalTab.tsx +++ b/src/components/tabs/TemporalTab.tsx @@ -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 - case TemporalGranularity.SEASON: - return - case TemporalGranularity.MONTH: - case TemporalGranularity.WEEK: - case TemporalGranularity.DAY: - default: - return + 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 ( -
- -
+ + {(isFullscreen) => ( +
+
+ +
+
+ )} +
) } diff --git a/src/lib/demo-data.ts b/src/lib/demo-data.ts new file mode 100644 index 0000000..69f7e4d --- /dev/null +++ b/src/lib/demo-data.ts @@ -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 + }) +} diff --git a/src/lib/types.ts b/src/lib/types.ts index eedbe7f..2b63f75 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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