feat: initial zoomcal-jeffemmett with full spatiotemporal calendar

Forked from cal-jeffemmett with:
- Leaflet map + spatiotemporal zoom
- @cal/shared for types, API, IFC, hooks
- Docker config for zoomcal.jeffemmett.com

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 12:30:45 -07:00
commit 0723957dca
43 changed files with 10387 additions and 0 deletions

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
# API Configuration
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
# Build mode
BUILD_STANDALONE=false

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Testing
coverage/
# Next.js
.next/
out/
# Production
build/
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Python
.venv/
__pycache__/
*.pyc

43
Dockerfile Normal file
View File

@ -0,0 +1,43 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install
# Copy source files
COPY . .
# Build the application
ENV BUILD_STANDALONE=true
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built application
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

15
backlog/config.yml Normal file
View File

@ -0,0 +1,15 @@
project_name: "Calendar Hub"
default_status: "To Do"
statuses: ["To Do", "In Progress", "Done"]
labels: []
milestones: []
date_format: yyyy-mm-dd
max_column_width: 20
default_editor: "nvim"
auto_open_browser: true
default_port: 6420
remote_operations: true
auto_commit: false
bypass_git_hooks: false
check_active_branches: true
active_branch_days: 30

View File

@ -0,0 +1,26 @@
---
id: task-1
title: Redesign time morphism display and add possibility cones view
status: To Do
assignee: []
created_date: '2025-12-27 04:23'
labels:
- visualization
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Improve the visual representation of time morphisms beyond simple zoom in/out arrows. Explore alternative UI patterns (gestures, radial menus, fluid transitions, etc.) that better convey the concept of moving between temporal scales. Additionally, implement a possibility cones view that visualizes potential futures branching from the present moment.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Replace zoom arrows with more intuitive time morphism controls
- [ ] #2 Time scale transitions feel fluid and conceptually coherent
- [ ] #3 Possibility cones view renders future branches from current moment
- [ ] #4 Cones can be expanded/collapsed to explore potential timelines
- [ ] #5 Visual language consistent with decolonized time philosophy
<!-- AC:END -->

View File

@ -0,0 +1,27 @@
---
id: task-10
title: Finish setting up schedule.jeffemmett.com
status: To Do
assignee: []
created_date: '2026-02-03 20:13'
labels: []
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Complete the self-hosted Cal.com deployment at schedule.jeffemmett.com. The instance is running (calcom, calcom-db, calcom-redis containers on Netcup) but needs final configuration steps.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Complete Cal.com setup wizard and create admin account
- [ ] #2 Set up Google Calendar OAuth credentials (Google Cloud Console → enable Calendar API → create Web OAuth client → redirect URI: https://schedule.jeffemmett.com/api/auth/callback/google)
- [ ] #3 Add GOOGLE_API_CREDENTIALS to /opt/websites/calcom/.env on Netcup and restart calcom container
- [ ] #4 Connect Google Calendar from Cal.com Settings → Calendars
- [ ] #5 Disable public signups (set NEXT_PUBLIC_DISABLE_SIGNUP=true in .env, restart)
- [ ] #6 Test booking flow end-to-end (create event type, make test booking, verify email notification via Resend)
- [ ] #7 Test Google Calendar sync (verify bookings appear in Google Calendar)
<!-- AC:END -->

View File

@ -0,0 +1,22 @@
---
id: task-2
title: Connect frontend to PKMN backend API
status: Done
assignee: []
created_date: '2026-01-02 13:53'
labels: []
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Wire up the calendar frontend to fetch events and sources from the PKMN backend API
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed: Created useEvents hook, updated MonthView and CalendarSidebar, added API proxy route for Cloudflare Access bypass
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,22 @@
---
id: task-3
title: Sync Google Calendar events to UnifiedEvent table
status: Done
assignee: []
created_date: '2026-01-02 14:27'
labels: []
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Create management command to sync Google Calendar events to the unified calendar system
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Completed: Created sync_google_to_unified command on Netcup, synced 4,903 events from 27 calendars
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,31 @@
---
id: task-4
title: Add event detail modal/panel
status: Done
assignee: []
created_date: '2026-01-02 14:27'
updated_date: '2026-01-02 16:30'
labels: []
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When clicking an event in the calendar, show a detail modal or side panel with full event information including description, location, attendees, and links
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
Created `EventDetailModal.tsx` component that displays:
- Event title with source color bar
- Date/time with duration
- IFC date display
- Recurring event indicator
- Location (physical or virtual with join link)
- Attendee count and organizer
- Full description with HTML rendering
- Source badge and event status
Wired up click handlers in MonthView to open the modal when clicking event chips.

View File

@ -0,0 +1,26 @@
---
id: task-5
title: Implement source visibility filtering
status: Done
assignee: []
created_date: '2026-01-02 14:28'
updated_date: '2026-01-02 16:45'
labels: []
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Wire up the source checkboxes in the sidebar to actually filter displayed events by calendar source
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
Refactored source visibility to use "hidden sources" approach:
- Changed `visibleSources` to `hiddenSources` in store (cleaner logic: empty = show all)
- Updated CalendarSidebar to check `!hiddenSources.includes(source.id)`
- Updated MonthView to filter events by hidden sources before display
- Event count header shows "X of Y events" when filtering is active
- State persisted via Zustand persist middleware

View File

@ -0,0 +1,16 @@
---
id: task-6
title: Add location-based event filtering
status: To Do
assignee: []
created_date: '2026-01-02 14:28'
labels: []
dependencies: []
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement the location tree in sidebar to filter events by geographic location using the spatial hierarchy from the backend
<!-- SECTION:DESCRIPTION:END -->

View File

@ -0,0 +1,16 @@
---
id: task-7
title: Add IFC month quick navigation
status: To Do
assignee: []
created_date: '2026-01-02 14:29'
labels: []
dependencies: []
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make the IFC Months section in sidebar clickable to navigate directly to that month in the calendar view
<!-- SECTION:DESCRIPTION:END -->

View File

@ -0,0 +1,26 @@
---
id: task-8
title: Implement calendar sync functionality
status: Done
assignee: []
created_date: '2026-01-02 14:29'
updated_date: '2026-01-02 17:00'
labels: []
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Wire up the 'Sync all calendars' button to trigger a re-sync of all Google Calendar sources via the backend API
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
- Added `syncAllSources()` function to api.ts that fetches active sources and syncs each in parallel
- Updated CalendarSidebar with sync button handler:
- Shows loading spinner during sync
- Shows success (green) or error (red) status
- Invalidates React Query cache to refetch events and sources
- Auto-resets to idle state after 3 seconds

View File

@ -0,0 +1,37 @@
---
id: task-9
title: Fix event_count in sources API
status: Done
assignee: []
created_date: '2026-01-02 14:30'
updated_date: '2026-01-02 17:10'
labels: [backend]
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
The event_count field shows 0 for all sources - need to update the sync command or serializer to compute correct counts
<!-- SECTION:DESCRIPTION:END -->
## Resolution
**No fix needed** - the backend already correctly computes `event_count`.
The serializer in `/opt/apps/pkmn/calendar_hub/serializers.py` already has:
```python
def get_event_count(self, obj):
return obj.events.count()
```
And the UnifiedEvent model has `related_name='events'` on the source FK.
Verified the API returns correct counts:
- jeffemmett@gmail.com: 1,955 events
- jeff@block.science: 888 events
- jessica.zartler@gmail.com: 859 events
- givethdotio@gmail.com: 663 events
The initial "0 for all sources" was because no events had been synced yet. After running `sync_google_to_unified`, the counts are correct.

25
docker-compose.yml Normal file
View File

@ -0,0 +1,25 @@
version: "3.8"
services:
zoomcal:
build: .
container_name: zoomcal-jeffemmett
restart: unless-stopped
environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=/api/v1/calendar
- BACKEND_URL=http://dko-backend:8000
labels:
- "traefik.enable=true"
- "traefik.http.routers.zoomcal.rule=Host(`zoomcal.jeffemmett.com`)"
- "traefik.http.routers.zoomcal.entrypoints=web"
- "traefik.http.services.zoomcal.loadbalancer.server.port=3000"
networks:
- traefik-public
- pkmn_pkmn-internal
networks:
traefik-public:
external: true
pkmn_pkmn-internal:
external: true

13
next.config.js Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ['@cal/shared'],
// API backend URL - use /api proxy in production
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || '/api/v1/calendar',
},
// Enable standalone mode for Docker deployment
output: 'standalone',
}
module.exports = nextConfig

6273
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "zoomcal-jeffemmett",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@cal/shared": "https://gitea.jeffemmett.com/jeffemmett/cal-shared/archive/main.tar.gz",
"@tanstack/react-query": "^5.17.0",
"clsx": "^2.1.0",
"date-fns": "^3.3.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.312.0",
"next": "^14.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-resizable-panels": "^4.6.2",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.56.0",
"eslint-config-next": "^14.1.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/.gitkeep Normal file
View File

@ -0,0 +1 @@
{}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View File

@ -0,0 +1,57 @@
import { NextRequest } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://dko-backend:8000'
async function proxyRequest(request: NextRequest, path: string[]) {
const pathStr = path.join('/')
const url = new URL(`/api/v1/calendar/${pathStr}${pathStr.endsWith('/') ? '' : '/'}`, BACKEND_URL)
// Forward query parameters
request.nextUrl.searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
const response = await fetch(url.toString(), {
method: request.method,
headers: {
'Content-Type': 'application/json',
},
})
const data = await response.json()
return Response.json(data, {
status: response.status,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
})
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path)
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path)
}
export async function OPTIONS() {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
})
}

110
src/app/globals.css Normal file
View File

@ -0,0 +1,110 @@
@import 'leaflet/dist/leaflet.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-inter: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb {
background: #4b5563;
}
/* Calendar grid styles */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
}
/* IFC Calendar - always 4 weeks */
.ifc-calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: repeat(4, minmax(80px, 1fr));
gap: 1px;
}
/* Event indicators */
.event-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
/* Toggle switch for calendar type */
.calendar-toggle {
position: relative;
display: inline-block;
width: 60px;
height: 28px;
}
.calendar-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.calendar-toggle .slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .3s;
border-radius: 28px;
}
.calendar-toggle .slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .3s;
border-radius: 50%;
}
.calendar-toggle input:checked + .slider {
background-color: #f59e0b;
}
.calendar-toggle input:checked + .slider:before {
transform: translateX(32px);
}
/* Granularity breadcrumb */
.location-breadcrumb {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.location-breadcrumb .separator {
color: #d1d5db;
}

17
src/app/icon.svg Normal file
View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect x="10" y="20" width="80" height="70" rx="8" fill="#3b82f6" stroke="#1e40af" stroke-width="3"/>
<rect x="10" y="20" width="80" height="18" rx="8" fill="#1e40af"/>
<rect x="10" y="30" width="80" height="8" fill="#1e40af"/>
<circle cx="30" cy="10" r="6" fill="#64748b"/>
<circle cx="70" cy="10" r="6" fill="#64748b"/>
<rect x="27" y="5" width="6" height="20" rx="2" fill="#64748b"/>
<rect x="67" y="5" width="6" height="20" rx="2" fill="#64748b"/>
<g fill="#fff">
<rect x="22" y="48" width="14" height="10" rx="2"/>
<rect x="43" y="48" width="14" height="10" rx="2"/>
<rect x="64" y="48" width="14" height="10" rx="2"/>
<rect x="22" y="66" width="14" height="10" rx="2"/>
<rect x="43" y="66" width="14" height="10" rx="2"/>
<rect x="64" y="66" width="14" height="10" rx="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 896 B

28
src/app/layout.tsx Normal file
View File

@ -0,0 +1,28 @@
import type { Metadata } from 'next'
import { Inter, JetBrains_Mono } from 'next/font/google'
import './globals.css'
import { Providers } from '@cal/shared'
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' })
export const metadata: Metadata = {
title: 'Spatiotemporal Calendar | zoomcal.jeffemmett.com',
description: 'Unified calendar with spatial hierarchy and International Fixed Calendar support',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body className="bg-gray-50 dark:bg-gray-900 min-h-screen">
<Providers>
{children}
</Providers>
</body>
</html>
)
}

245
src/app/page.tsx Normal file
View File

@ -0,0 +1,245 @@
'use client'
import { useState, useMemo, useEffect, useCallback } from 'react'
import { Calendar as CalendarIcon, MapPin, Clock, ZoomIn, ZoomOut, Map, Link2, Unlink2 } from 'lucide-react'
import { MonthView, SeasonView, YearView, TemporalZoomController, SplitView } from '@/components/calendar'
import { CalendarHeader } from '@/components/calendar/CalendarHeader'
import { CalendarSidebar } from '@/components/calendar/CalendarSidebar'
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
import {
useEvents,
TemporalGranularity,
TEMPORAL_GRANULARITY_LABELS,
GRANULARITY_LABELS,
} from '@cal/shared'
function CalendarView() {
const { temporalGranularity } = useCalendarStore()
// Render appropriate view based on temporal granularity
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 />
}
}
/** Computes the date range for the current temporal granularity. */
function getDateRangeForGranularity(
date: Date,
granularity: TemporalGranularity
): { start: string; end: string } {
const d = new Date(date)
let start: Date
let end: Date
switch (granularity) {
case TemporalGranularity.DAY:
start = new Date(d.getFullYear(), d.getMonth(), d.getDate())
end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1)
break
case TemporalGranularity.WEEK:
start = new Date(d)
start.setDate(d.getDate() - d.getDay())
end = new Date(start)
end.setDate(start.getDate() + 7)
break
case TemporalGranularity.MONTH:
start = new Date(d.getFullYear(), d.getMonth(), 1)
end = new Date(d.getFullYear(), d.getMonth() + 1, 0)
break
case TemporalGranularity.SEASON:
const qMonth = Math.floor(d.getMonth() / 3) * 3
start = new Date(d.getFullYear(), qMonth, 1)
end = new Date(d.getFullYear(), qMonth + 3, 0)
break
case TemporalGranularity.YEAR:
case TemporalGranularity.DECADE:
default:
start = new Date(d.getFullYear(), 0, 1)
end = new Date(d.getFullYear(), 11, 31)
break
}
return {
start: start.toISOString().split('T')[0],
end: end.toISOString().split('T')[0],
}
}
export default function Home() {
const [sidebarOpen, setSidebarOpen] = useState(true)
const [zoomPanelOpen, setZoomPanelOpen] = useState(false)
const {
calendarType,
temporalGranularity,
currentDate,
hiddenSources,
mapVisible,
toggleMap,
zoomCoupled,
toggleZoomCoupled,
zoomIn,
zoomOut,
} = useCalendarStore()
const effectiveSpatial = useEffectiveSpatialGranularity()
// Fetch events for the current view's date range (for the map)
const dateRange = useMemo(
() => getDateRangeForGranularity(currentDate, temporalGranularity),
[currentDate, temporalGranularity]
)
const { data: eventsData } = useEvents(dateRange)
const visibleEvents = useMemo(() => {
if (!eventsData?.results) return []
return eventsData.results.filter((e) => !hiddenSources.includes(e.source))
}, [eventsData?.results, hiddenSources])
// Keyboard shortcut for map toggle
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
if (e.key === 'm' || e.key === 'M') {
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault()
toggleMap()
}
}
if (e.key === 'l' || e.key === 'L') {
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault()
toggleZoomCoupled()
}
}
},
[toggleMap, toggleZoomCoupled]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
return (
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
{/* Sidebar */}
{sidebarOpen && (
<CalendarSidebar onClose={() => setSidebarOpen(false)} />
)}
{/* Main content */}
<div className="flex-1 flex flex-col overflow-hidden">
<CalendarHeader
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
sidebarOpen={sidebarOpen}
/>
{/* Main area with optional zoom panel */}
<div className="flex-1 flex overflow-hidden">
{/* Calendar + Map split view */}
<div className="flex-1 overflow-hidden">
{mapVisible ? (
<SplitView
calendarContent={<CalendarView />}
events={visibleEvents}
/>
) : (
<main className="h-full overflow-auto p-4">
<CalendarView />
</main>
)}
</div>
{/* Zoom control panel (collapsible) */}
{zoomPanelOpen && (
<aside className="w-80 border-l border-gray-200 dark:border-gray-700 p-4 overflow-auto bg-white dark:bg-gray-800">
<TemporalZoomController showSpatial={true} />
</aside>
)}
</div>
{/* Footer with calendar info and quick zoom controls */}
<footer className="border-t border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800">
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<CalendarIcon className="w-4 h-4" />
{calendarType === 'gregorian' ? 'Gregorian' : 'IFC'}
</span>
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{TEMPORAL_GRANULARITY_LABELS[temporalGranularity]}
</span>
<span className="flex items-center gap-1">
<MapPin className="w-4 h-4" />
{GRANULARITY_LABELS[effectiveSpatial]}
</span>
{mapVisible && (
<button
onClick={toggleZoomCoupled}
className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors ${
zoomCoupled
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={zoomCoupled ? 'Unlink spatial from temporal (L)' : 'Link spatial to temporal (L)'}
>
{zoomCoupled ? <Link2 className="w-3 h-3" /> : <Unlink2 className="w-3 h-3" />}
{zoomCoupled ? 'Coupled' : 'Independent'}
</button>
)}
</div>
{/* Quick controls */}
<div className="flex items-center gap-2">
<button
onClick={zoomIn}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Zoom in (+)"
>
<ZoomIn className="w-4 h-4" />
</button>
<button
onClick={zoomOut}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Zoom out (-)"
>
<ZoomOut className="w-4 h-4" />
</button>
<button
onClick={toggleMap}
className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
mapVisible
? 'bg-green-500 text-white'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="Toggle map (M)"
>
<Map className="w-3 h-3" />
{mapVisible ? 'Hide Map' : 'Map'}
</button>
<button
onClick={() => setZoomPanelOpen(!zoomPanelOpen)}
className={`px-2 py-1 text-xs rounded transition-colors ${
zoomPanelOpen
? 'bg-blue-500 text-white'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{zoomPanelOpen ? 'Hide' : 'Zoom Panel'}
</button>
</div>
</div>
</footer>
</div>
</div>
)
}

View File

@ -0,0 +1,227 @@
'use client'
import { ChevronLeft, ChevronRight, Menu, Calendar, Moon, Settings, MapPin, ZoomIn, ZoomOut } from 'lucide-react'
import { useCalendarStore } from '@/lib/store'
import {
IFC_MONTHS,
TemporalGranularity,
TEMPORAL_GRANULARITY_LABELS,
gregorianToIFC,
} from '@cal/shared'
import { clsx } from 'clsx'
interface CalendarHeaderProps {
onToggleSidebar: () => void
sidebarOpen: boolean
}
export function CalendarHeader({ onToggleSidebar, sidebarOpen }: CalendarHeaderProps) {
const {
currentDate,
calendarType,
toggleCalendarType,
goToToday,
navigateByGranularity,
temporalGranularity,
setTemporalGranularity,
zoomIn,
zoomOut,
showIFCDates,
setShowIFCDates,
} = useCalendarStore()
const ifc = gregorianToIFC(currentDate)
// Format the display based on temporal granularity
const getDateDisplay = () => {
switch (temporalGranularity) {
case TemporalGranularity.DECADE: {
const decadeStart = Math.floor(currentDate.getFullYear() / 10) * 10
return `${decadeStart}${decadeStart + 9}`
}
case TemporalGranularity.YEAR:
return calendarType === 'ifc'
? `${ifc.year} (IFC)`
: currentDate.getFullYear().toString()
case TemporalGranularity.MONTH:
return calendarType === 'ifc'
? `${IFC_MONTHS[ifc.month - 1]} ${ifc.year}`
: currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
case TemporalGranularity.WEEK: {
const weekStart = new Date(currentDate)
weekStart.setDate(currentDate.getDate() - currentDate.getDay())
const weekEnd = new Date(weekStart)
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.DAY:
return currentDate.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})
default:
return currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
}
}
const currentDisplay = getDateDisplay()
return (
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between px-4 py-3">
{/* Left section */}
<div className="flex items-center gap-4">
<button
onClick={onToggleSidebar}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Toggle sidebar"
>
<Menu className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
<div className="flex items-center gap-2">
<Calendar className="w-6 h-6 text-blue-500" />
<h1 className="text-xl font-semibold text-gray-900 dark:text-white hidden sm:block">
Calendar Hub
</h1>
</div>
</div>
{/* Center section - navigation */}
<div className="flex items-center gap-2">
{/* Zoom out button */}
<button
onClick={zoomOut}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Zoom out (-)"
>
<ZoomOut className="w-4 h-4 text-gray-600 dark:text-gray-400" />
</button>
<button
onClick={() => navigateByGranularity('prev')}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label={`Previous ${TEMPORAL_GRANULARITY_LABELS[temporalGranularity]}`}
>
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
<button
onClick={goToToday}
className="px-3 py-1.5 text-sm font-medium bg-blue-500 text-white hover:bg-blue-600 rounded-lg transition-colors"
>
Today
</button>
<div className="flex flex-col items-center min-w-[200px]">
<span className="text-lg font-semibold text-gray-900 dark:text-white">
{currentDisplay}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{TEMPORAL_GRANULARITY_LABELS[temporalGranularity]} view
</span>
</div>
<button
onClick={() => navigateByGranularity('next')}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label={`Next ${TEMPORAL_GRANULARITY_LABELS[temporalGranularity]}`}
>
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
{/* Zoom in button */}
<button
onClick={zoomIn}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Zoom in (+)"
>
<ZoomIn className="w-4 h-4 text-gray-600 dark:text-gray-400" />
</button>
</div>
{/* Right section - calendar type toggle and settings */}
<div className="flex items-center gap-3">
{/* IFC toggle */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:block">
{calendarType === 'gregorian' ? 'Gregorian' : 'IFC'}
</span>
<button
onClick={toggleCalendarType}
className={clsx(
'relative inline-flex h-7 w-14 items-center rounded-full transition-colors',
calendarType === 'ifc'
? 'bg-amber-500'
: 'bg-gray-300 dark:bg-gray-600'
)}
aria-label="Toggle calendar type"
>
<span
className={clsx(
'inline-flex h-5 w-5 items-center justify-center rounded-full bg-white transition-transform',
calendarType === 'ifc' ? 'translate-x-8' : 'translate-x-1'
)}
>
{calendarType === 'ifc' ? (
<Moon className="w-3 h-3 text-amber-500" />
) : (
<Calendar className="w-3 h-3 text-gray-500" />
)}
</span>
</button>
</div>
{/* Show IFC dates toggle (when in Gregorian mode) */}
{calendarType === 'gregorian' && (
<button
onClick={() => setShowIFCDates(!showIFCDates)}
className={clsx(
'p-2 rounded-lg transition-colors',
showIFCDates
? 'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400'
: 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
)}
title={showIFCDates ? 'Hide IFC dates' : 'Show IFC dates'}
>
<Moon className="w-5 h-5" />
</button>
)}
{/* Location filter */}
<button
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-400"
title="Filter by location"
>
<MapPin className="w-5 h-5" />
</button>
{/* Settings */}
<button
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-400"
title="Settings"
>
<Settings className="w-5 h-5" />
</button>
</div>
</div>
{/* Dual calendar indicator */}
{calendarType === 'gregorian' && showIFCDates && (
<div className="px-4 py-1.5 bg-amber-50 dark:bg-amber-900/20 border-t border-amber-100 dark:border-amber-800/30">
<div className="flex items-center gap-2 text-sm">
<Moon className="w-4 h-4 text-amber-500" />
<span className="text-amber-700 dark:text-amber-300">
IFC: {IFC_MONTHS[ifc.month - 1]} {ifc.day}, {ifc.year}
</span>
<span className="text-amber-500 dark:text-amber-400 text-xs">
(Today in International Fixed Calendar)
</span>
</div>
</div>
)}
</header>
)
}

View File

@ -0,0 +1,389 @@
'use client'
import { X, Calendar, MapPin, ChevronDown, ChevronRight, Plus, RefreshCw, Loader2, Check, AlertCircle } from 'lucide-react'
import { useState } from 'react'
import { useCalendarStore } from '@/lib/store'
import {
IFC_MONTHS,
GRANULARITY_LABELS,
SpatialGranularity,
gregorianToIFC,
getIFCMonthColor,
useSources,
syncAllSources,
} from '@cal/shared'
import { useQueryClient } from '@tanstack/react-query'
import { clsx } from 'clsx'
interface CalendarSidebarProps {
onClose: () => void
}
export function CalendarSidebar({ onClose }: CalendarSidebarProps) {
const [sourcesExpanded, setSourcesExpanded] = useState(true)
const [locationsExpanded, setLocationsExpanded] = useState(true)
const [ifcMonthsExpanded, setIFCMonthsExpanded] = useState(false)
const [isSyncing, setIsSyncing] = useState(false)
const [syncStatus, setSyncStatus] = useState<'idle' | 'success' | 'error'>('idle')
const { calendarType, currentDate, setCurrentDate, hiddenSources, toggleSourceVisibility } = useCalendarStore()
const currentIFC = gregorianToIFC(currentDate)
const queryClient = useQueryClient()
// Fetch real sources from API
const { data: sourcesData, isLoading: sourcesLoading } = useSources()
const sources = sourcesData?.results || []
const handleSync = async () => {
setIsSyncing(true)
setSyncStatus('idle')
try {
const results = await syncAllSources()
const hasErrors = results.some((r) => r.status === 'rejected')
setSyncStatus(hasErrors ? 'error' : 'success')
// Invalidate queries to refetch data
await queryClient.invalidateQueries({ queryKey: ['events'] })
await queryClient.invalidateQueries({ queryKey: ['sources'] })
} catch {
setSyncStatus('error')
} finally {
setIsSyncing(false)
// Reset status after 3 seconds
setTimeout(() => setSyncStatus('idle'), 3000)
}
}
const locationTree = [
{
name: 'Earth',
granularity: SpatialGranularity.PLANET,
children: [
{
name: 'Europe',
granularity: SpatialGranularity.CONTINENT,
children: [
{ name: 'Germany', granularity: SpatialGranularity.COUNTRY },
{ name: 'France', granularity: SpatialGranularity.COUNTRY },
],
},
{
name: 'North America',
granularity: SpatialGranularity.CONTINENT,
children: [
{ name: 'United States', granularity: SpatialGranularity.COUNTRY },
{ name: 'Canada', granularity: SpatialGranularity.COUNTRY },
],
},
],
},
]
return (
<aside className="w-72 border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 className="font-semibold text-gray-900 dark:text-white">Calendars</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
<div className="flex-1 overflow-auto p-4 space-y-6">
{/* Mini calendar */}
<MiniCalendar />
{/* Calendar Sources */}
<section>
<button
onClick={() => setSourcesExpanded(!sourcesExpanded)}
className="flex items-center gap-2 w-full text-left mb-2"
>
{sourcesExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
<Calendar className="w-4 h-4 text-gray-500" />
<span className="font-medium text-gray-700 dark:text-gray-300">Sources</span>
</button>
{sourcesExpanded && (
<div className="space-y-1 ml-6">
{sourcesLoading ? (
<div className="flex items-center gap-2 py-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" />
Loading sources...
</div>
) : sources.length === 0 ? (
<div className="text-sm text-gray-500 py-2">No calendars found</div>
) : (
sources.map((source) => {
const isVisible = !hiddenSources.includes(source.id)
return (
<label key={source.id} className="flex items-center gap-2 py-1 cursor-pointer">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggleSourceVisibility(source.id)}
className="rounded border-gray-300"
style={{ accentColor: source.color }}
/>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: source.color }}
/>
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1 truncate" title={source.name}>
{source.name}
</span>
<span className="text-xs text-gray-400">{source.event_count}</span>
</label>
)
})
)}
<button className="flex items-center gap-2 py-1 text-sm text-blue-500 hover:text-blue-600">
<Plus className="w-4 h-4" />
Add calendar
</button>
</div>
)}
</section>
{/* Location Filter */}
<section>
<button
onClick={() => setLocationsExpanded(!locationsExpanded)}
className="flex items-center gap-2 w-full text-left mb-2"
>
{locationsExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
<MapPin className="w-4 h-4 text-gray-500" />
<span className="font-medium text-gray-700 dark:text-gray-300">Locations</span>
</button>
{locationsExpanded && (
<div className="ml-6 space-y-1">
<LocationTree items={locationTree} />
</div>
)}
</section>
{/* IFC Months Quick Nav */}
<section>
<button
onClick={() => setIFCMonthsExpanded(!ifcMonthsExpanded)}
className="flex items-center gap-2 w-full text-left mb-2"
>
{ifcMonthsExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
<span className="font-medium text-gray-700 dark:text-gray-300">IFC Months</span>
</button>
{ifcMonthsExpanded && (
<div className="ml-6 grid grid-cols-2 gap-1">
{IFC_MONTHS.map((month, i) => (
<button
key={month}
className={clsx(
'text-xs py-1 px-2 rounded text-left transition-colors',
currentIFC.month === i + 1
? 'ring-2 ring-offset-1'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
)}
style={{
backgroundColor:
currentIFC.month === i + 1
? `${getIFCMonthColor(i + 1)}20`
: undefined,
color:
currentIFC.month === i + 1
? getIFCMonthColor(i + 1)
: undefined,
['--tw-ring-color' as string]: getIFCMonthColor(i + 1),
}}
>
{month}
</button>
))}
</div>
)}
</section>
</div>
{/* Sync button */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleSync}
disabled={isSyncing}
className={clsx(
'w-full flex items-center justify-center gap-2 py-2 px-4 text-sm rounded-lg transition-colors',
syncStatus === 'success' && 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
syncStatus === 'error' && 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
syncStatus === 'idle' && 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700',
isSyncing && 'opacity-50 cursor-not-allowed'
)}
>
{isSyncing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Syncing...
</>
) : syncStatus === 'success' ? (
<>
<Check className="w-4 h-4" />
Synced!
</>
) : syncStatus === 'error' ? (
<>
<AlertCircle className="w-4 h-4" />
Sync failed
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
Sync all calendars
</>
)}
</button>
</div>
</aside>
)
}
function MiniCalendar() {
const { currentDate, setCurrentDate, goToNextMonth, goToPreviousMonth } = useCalendarStore()
const year = currentDate.getFullYear()
const month = currentDate.getMonth()
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const daysInMonth = lastDay.getDate()
const startDayOfWeek = firstDay.getDay()
const today = new Date()
today.setHours(0, 0, 0, 0)
const days: (number | null)[] = []
// Empty cells before first day
for (let i = 0; i < startDayOfWeek; i++) {
days.push(null)
}
// Days of month
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return (
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<button
onClick={goToPreviousMonth}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
<ChevronDown className="w-4 h-4 rotate-90 text-gray-500" />
</button>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{currentDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</span>
<button
onClick={goToNextMonth}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
<ChevronDown className="w-4 h-4 -rotate-90 text-gray-500" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center">
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => (
<div key={i} className="text-xs text-gray-400 py-1">
{d}
</div>
))}
{days.map((day, i) => {
if (day === null) {
return <div key={i} />
}
const d = new Date(year, month, day)
const isToday = d.getTime() === today.getTime()
const isSelected = d.toDateString() === currentDate.toDateString()
return (
<button
key={i}
onClick={() => setCurrentDate(d)}
className={clsx(
'text-xs py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700',
isToday && 'bg-blue-500 text-white hover:bg-blue-600',
isSelected && !isToday && 'bg-gray-200 dark:bg-gray-700'
)}
>
{day}
</button>
)
})}
</div>
</div>
)
}
function LocationTree({
items,
depth = 0,
}: {
items: Array<{
name: string
granularity: SpatialGranularity
children?: Array<{ name: string; granularity: SpatialGranularity; children?: any[] }>
}>
depth?: number
}) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
return (
<>
{items.map((item) => (
<div key={item.name}>
<button
onClick={() =>
setExpanded((prev) => ({ ...prev, [item.name]: !prev[item.name] }))
}
className="flex items-center gap-1 py-1 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white w-full text-left"
style={{ paddingLeft: `${depth * 12}px` }}
>
{item.children?.length ? (
expanded[item.name] ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)
) : (
<span className="w-3" />
)}
<span>{item.name}</span>
<span className="text-xs text-gray-400 ml-1">
({GRANULARITY_LABELS[item.granularity]})
</span>
</button>
{item.children && expanded[item.name] && (
<LocationTree items={item.children} depth={depth + 1} />
)}
</div>
))}
</>
)
}

View File

@ -0,0 +1,229 @@
'use client'
import { X, MapPin, Calendar, Clock, Video, Users, ExternalLink, Repeat } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { getEvent, getSemanticLocationLabel, UnifiedEvent } from '@cal/shared'
import { useEffectiveSpatialGranularity } from '@/lib/store'
import { clsx } from 'clsx'
interface EventDetailModalProps {
eventId: string
onClose: () => void
}
export function EventDetailModal({ eventId, onClose }: EventDetailModalProps) {
const { data: event, isLoading, error } = useQuery<UnifiedEvent>({
queryKey: ['event', eventId],
queryFn: () => getEvent(eventId) as Promise<UnifiedEvent>,
})
const effectiveSpatial = useEffectiveSpatialGranularity()
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
})
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})
}
const formatDuration = (minutes: number) => {
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-hidden">
{/* Color bar */}
{event && (
<div
className="h-2"
style={{ backgroundColor: event.source_color }}
/>
)}
{/* Header */}
<div className="flex items-start justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex-1 pr-4">
{isLoading ? (
<div className="h-6 w-48 bg-gray-200 dark:bg-gray-600 rounded animate-pulse" />
) : error ? (
<span className="text-red-500">Error loading event</span>
) : (
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{event?.title}
</h2>
)}
</div>
<button
onClick={onClose}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Content */}
<div className="p-4 overflow-auto max-h-[calc(90vh-120px)] space-y-4">
{isLoading ? (
<div className="space-y-3">
<div className="h-4 w-full bg-gray-200 dark:bg-gray-600 rounded animate-pulse" />
<div className="h-4 w-3/4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse" />
<div className="h-4 w-1/2 bg-gray-200 dark:bg-gray-600 rounded animate-pulse" />
</div>
) : event ? (
<>
{/* Date & Time */}
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<div className="text-gray-900 dark:text-white">
{formatDate(event.start)}
</div>
{!event.all_day && (
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-2">
<Clock className="w-4 h-4" />
{formatTime(event.start)} {formatTime(event.end)}
<span className="text-gray-400">
({formatDuration(event.duration_minutes)})
</span>
</div>
)}
{event.all_day && (
<span className="text-sm text-gray-500 dark:text-gray-400">All day</span>
)}
</div>
</div>
{/* IFC Date */}
{event.ifc_display && (
<div className="flex items-start gap-3">
<div className="w-5 h-5 flex items-center justify-center text-amber-500 flex-shrink-0">
🌙
</div>
<div className="text-amber-600 dark:text-amber-400 text-sm">
IFC: {event.ifc_display}
</div>
</div>
)}
{/* Recurring */}
{event.is_recurring && event.rrule && (
<div className="flex items-start gap-3">
<Repeat className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-gray-600 dark:text-gray-400">
Recurring event
</div>
</div>
)}
{/* Location */}
{(event.location_raw || event.is_virtual) && (
<div className="flex items-start gap-3">
{event.is_virtual ? (
<Video className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
) : (
<MapPin className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
)}
<div>
{event.location_raw && (() => {
const semanticLabel = getSemanticLocationLabel(event, effectiveSpatial)
return (
<>
<div className="text-gray-900 dark:text-white">
{semanticLabel || event.location_raw}
</div>
{semanticLabel && semanticLabel !== event.location_raw && (
<div className="text-xs text-gray-400 mt-0.5">{event.location_raw}</div>
)}
</>
)
})()}
{event.is_virtual && event.virtual_url && (
<a
href={event.virtual_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-500 hover:text-blue-600 flex items-center gap-1"
>
{event.virtual_platform || 'Join meeting'}
<ExternalLink className="w-3 h-3" />
</a>
)}
</div>
</div>
)}
{/* Attendees */}
{event.attendee_count > 0 && (
<div className="flex items-start gap-3">
<Users className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{event.attendee_count} attendee{event.attendee_count !== 1 ? 's' : ''}
</div>
{event.organizer_name && (
<div className="text-xs text-gray-500">
Organized by {event.organizer_name}
</div>
)}
</div>
</div>
)}
{/* Description */}
{event.description && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<div
className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: event.description }}
/>
</div>
)}
{/* Source badge */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: event.source_color }}
/>
<span className="text-sm text-gray-500 dark:text-gray-400">
{event.source_name}
</span>
</div>
<span
className={clsx(
'text-xs px-2 py-0.5 rounded',
event.status === 'confirmed' && 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
event.status === 'tentative' && 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
event.status === 'cancelled' && 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
)}
>
{event.status}
</span>
</div>
</>
) : null}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,367 @@
'use client'
import { useMemo } from 'react'
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
import {
gregorianToIFC,
getIFCMonthColor,
getSemanticLocationLabel,
IFC_MONTHS,
IFC_WEEKDAYS,
EventListItem,
useMonthEvents,
groupEventsByDate,
} from '@cal/shared'
import { EventDetailModal } from './EventDetailModal'
import { clsx } from 'clsx'
interface DayCell {
date: Date
dateKey: string
day: number
isCurrentMonth: boolean
isToday: boolean
ifcDate?: {
month: number
day: number
monthName: string
isSpecial: boolean
}
}
export function MonthView() {
const { currentDate, calendarType, showIFCDates, selectedEventId, setSelectedEventId, hiddenSources } = useCalendarStore()
const effectiveSpatial = useEffectiveSpatialGranularity()
const year = currentDate.getFullYear()
const month = currentDate.getMonth() + 1
// Fetch events for the current month
const { data: eventsData, isLoading } = useMonthEvents(year, month)
// Group events by date, filtering out hidden sources
const eventsByDate = useMemo(() => {
if (!eventsData?.results) return new Map<string, EventListItem[]>()
const visibleEvents = eventsData.results.filter(
(event) => !hiddenSources.includes(event.source)
)
return groupEventsByDate(visibleEvents)
}, [eventsData?.results, hiddenSources])
const monthData = useMemo(() => {
if (calendarType === 'ifc') {
return generateIFCMonth(currentDate)
}
return generateGregorianMonth(currentDate)
}, [currentDate, calendarType])
const weekdays = calendarType === 'ifc'
? IFC_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">
{/* Month header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{calendarType === 'ifc'
? `${IFC_MONTHS[monthData.month - 1]} ${monthData.year}`
: new Date(monthData.year, monthData.month - 1).toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
})}
</h2>
{eventsData && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{hiddenSources.length > 0 ? (
<>
{eventsData.results.filter(e => !hiddenSources.includes(e.source)).length} of {eventsData.count} events
</>
) : (
<>{eventsData.count} events</>
)}
</span>
)}
</div>
{calendarType === 'ifc' && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
International Fixed Calendar - 28 days, always starts on Sunday
</p>
)}
</div>
{/* Calendar grid */}
<div className="p-2">
{/* Weekday headers */}
<div className="grid grid-cols-7 gap-px mb-1">
{weekdays.map((day, i) => (
<div
key={day}
className={clsx(
'text-center text-sm font-medium py-2',
i === 0 ? 'text-red-500' : 'text-gray-600 dark:text-gray-400',
i === 6 && 'text-blue-500'
)}
>
{typeof day === 'string' ? day.slice(0, 3) : day}
</div>
))}
</div>
{/* Day grid */}
<div
className={clsx(
'grid grid-cols-7 gap-px bg-gray-100 dark:bg-gray-700',
calendarType === 'ifc' ? 'ifc-calendar-grid' : 'calendar-grid'
)}
>
{monthData.days.map((day, i) => (
<DayCellComponent
key={i}
day={day}
calendarType={calendarType}
showIFCDates={showIFCDates}
events={eventsByDate.get(day.dateKey) || []}
isLoading={isLoading}
onEventClick={setSelectedEventId}
effectiveSpatial={effectiveSpatial}
/>
))}
</div>
</div>
{/* Event detail modal */}
{selectedEventId && (
<EventDetailModal
eventId={selectedEventId}
onClose={() => setSelectedEventId(null)}
/>
)}
</div>
)
}
function DayCellComponent({
day,
calendarType,
showIFCDates,
events,
isLoading,
onEventClick,
effectiveSpatial,
}: {
day: DayCell
calendarType: 'gregorian' | 'ifc'
showIFCDates: boolean
events: EventListItem[]
isLoading: boolean
onEventClick: (eventId: string | null) => void
effectiveSpatial: number
}) {
const isWeekend = day.date.getDay() === 0 || day.date.getDay() === 6
const maxVisibleEvents = 3
const hasMoreEvents = events.length > maxVisibleEvents
const visibleEvents = events.slice(0, maxVisibleEvents)
return (
<div
className={clsx(
'min-h-[100px] p-2 bg-white dark:bg-gray-800 transition-colors',
!day.isCurrentMonth && 'bg-gray-50 dark:bg-gray-900 opacity-50',
day.isToday && 'ring-2 ring-blue-500 ring-inset',
'hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer'
)}
>
{/* Day number */}
<div className="flex items-start justify-between">
<span
className={clsx(
'inline-flex items-center justify-center w-7 h-7 rounded-full text-sm font-medium',
day.isToday && 'bg-blue-500 text-white',
!day.isToday && isWeekend && 'text-gray-400 dark:text-gray-500',
!day.isToday && !isWeekend && 'text-gray-900 dark:text-white'
)}
>
{day.day}
</span>
{/* IFC date indicator */}
{calendarType === 'gregorian' && showIFCDates && day.ifcDate && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${getIFCMonthColor(day.ifcDate.month)}20`,
color: getIFCMonthColor(day.ifcDate.month),
}}
title={`IFC: ${day.ifcDate.monthName} ${day.ifcDate.day}`}
>
{day.ifcDate.monthName.slice(0, 1)}{day.ifcDate.day}
</span>
)}
</div>
{/* Events */}
<div className="mt-1 space-y-0.5">
{isLoading ? (
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse" />
) : (
<>
{visibleEvents.map((event) => {
const locationLabel = getSemanticLocationLabel(event, effectiveSpatial)
return (
<div
key={event.id}
className="text-xs px-1.5 py-0.5 rounded truncate cursor-pointer hover:opacity-80 transition-opacity"
style={{
backgroundColor: event.source_color || '#3b82f6',
color: '#fff',
}}
title={`${event.title}${locationLabel ? ` - ${locationLabel}` : ''}${event.all_day ? ' (All day)' : ''}`}
onClick={(e) => {
e.stopPropagation()
onEventClick(event.id)
}}
>
{!event.all_day && (
<span className="opacity-75 mr-1">
{new Date(event.start).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
})}
</span>
)}
{event.title}
{locationLabel && (
<span className="opacity-60 ml-1 text-[10px]">{locationLabel}</span>
)}
</div>
)
})}
{hasMoreEvents && (
<div className="text-xs text-gray-500 dark:text-gray-400 px-1.5">
+{events.length - maxVisibleEvents} more
</div>
)}
</>
)}
</div>
</div>
)
}
function generateGregorianMonth(date: Date): {
year: number
month: number
days: DayCell[]
} {
const year = date.getFullYear()
const month = date.getMonth() + 1
const firstDay = new Date(year, month - 1, 1)
const lastDay = new Date(year, month, 0)
const daysInMonth = lastDay.getDate()
const startDayOfWeek = firstDay.getDay()
const today = new Date()
today.setHours(0, 0, 0, 0)
const days: DayCell[] = []
// Previous month's trailing days
const prevMonth = new Date(year, month - 2, 1)
const daysInPrevMonth = new Date(year, month - 1, 0).getDate()
for (let i = startDayOfWeek - 1; i >= 0; i--) {
const dayNum = daysInPrevMonth - i
const d = new Date(prevMonth.getFullYear(), prevMonth.getMonth(), dayNum)
const ifc = gregorianToIFC(d)
days.push({
date: d,
dateKey: d.toISOString().split('T')[0],
day: dayNum,
isCurrentMonth: false,
isToday: false,
ifcDate: {
month: ifc.month,
day: ifc.day,
monthName: ifc.month_name,
isSpecial: ifc.is_year_day || ifc.is_leap_day,
},
})
}
// Current month's days
for (let i = 1; i <= daysInMonth; i++) {
const d = new Date(year, month - 1, i)
const ifc = gregorianToIFC(d)
days.push({
date: d,
dateKey: d.toISOString().split('T')[0],
day: i,
isCurrentMonth: true,
isToday: d.getTime() === today.getTime(),
ifcDate: {
month: ifc.month,
day: ifc.day,
monthName: ifc.month_name,
isSpecial: ifc.is_year_day || ifc.is_leap_day,
},
})
}
// Next month's leading days
const remainingDays = 42 - days.length // 6 rows x 7 days
for (let i = 1; i <= remainingDays; i++) {
const d = new Date(year, month, i)
const ifc = gregorianToIFC(d)
days.push({
date: d,
dateKey: d.toISOString().split('T')[0],
day: i,
isCurrentMonth: false,
isToday: false,
ifcDate: {
month: ifc.month,
day: ifc.day,
monthName: ifc.month_name,
isSpecial: ifc.is_year_day || ifc.is_leap_day,
},
})
}
return { year, month, days }
}
function generateIFCMonth(date: Date): {
year: number
month: number
days: DayCell[]
} {
const ifc = gregorianToIFC(date)
const year = ifc.year
const month = ifc.month
const todayIFC = gregorianToIFC(new Date())
const days: DayCell[] = []
// IFC months always have exactly 28 days, starting on Sunday
for (let day = 1; day <= 28; day++) {
// Convert IFC date back to Gregorian for the Date object
const gregorianDate = new Date(date)
gregorianDate.setDate(day)
days.push({
date: gregorianDate,
dateKey: gregorianDate.toISOString().split('T')[0],
day,
isCurrentMonth: true,
isToday:
year === todayIFC.year &&
month === todayIFC.month &&
day === todayIFC.day,
})
}
return { year, month, days }
}

View File

@ -0,0 +1,208 @@
'use client'
import { useMemo } from 'react'
import { useCalendarStore } from '@/lib/store'
import {
gregorianToIFC,
getIFCMonthColor,
isLeapYear,
IFC_MONTHS,
IFC_SEASONS,
TemporalGranularity,
} from '@cal/shared'
import { clsx } from 'clsx'
interface MonthGridProps {
year: number
month: number // 1-indexed
calendarType: 'gregorian' | 'ifc'
onDayClick?: (date: Date) => void
}
function MonthGrid({ year, month, calendarType, onDayClick }: MonthGridProps) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const monthData = useMemo(() => {
if (calendarType === 'ifc') {
// IFC: Always 28 days, always starts on Sunday
const days: { day: number; date: Date; isToday: boolean }[] = []
const todayIFC = gregorianToIFC(today)
for (let day = 1; day <= 28; day++) {
// Convert IFC date back to Gregorian for the actual Date object
const ifcDayOfYear = (month - 1) * 28 + day
let gregorianDayOfYear = ifcDayOfYear
if (isLeapYear(year) && ifcDayOfYear >= 169) {
gregorianDayOfYear += 1
}
const date = new Date(year, 0, gregorianDayOfYear)
days.push({
day,
date,
isToday: todayIFC.year === year && todayIFC.month === month && todayIFC.day === day,
})
}
return { days, startDay: 0, daysInMonth: 28 }
} else {
// Gregorian
const firstDay = new Date(year, month - 1, 1)
const lastDay = new Date(year, month, 0)
const daysInMonth = lastDay.getDate()
const startDay = firstDay.getDay()
const days: { day: number; date: Date; isToday: boolean }[] = []
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month - 1, day)
days.push({
day,
date,
isToday: date.getTime() === today.getTime(),
})
}
return { days, startDay, daysInMonth }
}
}, [year, month, calendarType, today])
const monthName = calendarType === 'ifc'
? IFC_MONTHS[month - 1]
: new Date(year, month - 1).toLocaleDateString('en-US', { month: 'long' })
const monthColor = calendarType === 'ifc' ? getIFCMonthColor(month) : undefined
return (
<div className="flex-1 min-w-0">
{/* Month header */}
<div
className="text-center py-2 rounded-t-lg font-semibold"
style={{
backgroundColor: monthColor ? `${monthColor}20` : 'rgb(243 244 246)',
color: monthColor || 'rgb(55 65 81)',
}}
>
{monthName}
</div>
{/* Calendar grid */}
<div className="border border-gray-200 dark:border-gray-700 rounded-b-lg overflow-hidden">
{/* Weekday headers */}
<div className="grid grid-cols-7 bg-gray-50 dark:bg-gray-800">
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
<div
key={i}
className={clsx(
'text-center text-xs py-1 font-medium',
i === 0 ? 'text-red-500' : i === 6 ? 'text-blue-500' : 'text-gray-500'
)}
>
{day}
</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{/* Empty cells for start offset */}
{Array.from({ length: monthData.startDay }).map((_, i) => (
<div key={`empty-${i}`} className="aspect-square border-t border-l border-gray-100 dark:border-gray-700" />
))}
{/* Day cells */}
{monthData.days.map(({ day, date, isToday }) => (
<button
key={day}
onClick={() => onDayClick?.(date)}
className={clsx(
'aspect-square flex items-center justify-center text-sm',
'border-t border-l border-gray-100 dark:border-gray-700',
'hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
isToday && 'bg-blue-500 text-white font-bold hover:bg-blue-600'
)}
>
{day}
</button>
))}
</div>
</div>
</div>
)
}
export function SeasonView() {
const {
currentDate,
calendarType,
setCurrentDate,
setTemporalGranularity,
} = useCalendarStore()
const year = currentDate.getFullYear()
// Determine current quarter/season
const currentMonth = calendarType === 'ifc'
? gregorianToIFC(currentDate).month
: currentDate.getMonth() + 1
// Calculate which quarter we're in
const currentQuarter = calendarType === 'ifc'
? IFC_SEASONS.findIndex(s => s.months.includes(currentMonth)) + 1
: Math.ceil(currentMonth / 3)
// Get the months for this quarter
const quarterMonths = useMemo(() => {
if (calendarType === 'ifc') {
const season = IFC_SEASONS[currentQuarter - 1]
return season?.months || [1, 2, 3]
} else {
const startMonth = (currentQuarter - 1) * 3 + 1
return [startMonth, startMonth + 1, startMonth + 2]
}
}, [calendarType, currentQuarter])
const seasonName = calendarType === 'ifc'
? IFC_SEASONS[currentQuarter - 1]?.name || 'Season'
: ['Winter', 'Spring', 'Summer', 'Fall'][currentQuarter - 1] || 'Quarter'
const handleDayClick = (date: Date) => {
setCurrentDate(date)
setTemporalGranularity(TemporalGranularity.DAY)
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
{/* Season header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white text-center">
{seasonName} {year}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 text-center mt-1">
Q{currentQuarter} {calendarType === 'ifc' ? 'IFC' : 'Gregorian'} {' '}
{quarterMonths.length} months
</p>
</div>
{/* Three month grid */}
<div className="p-4">
<div className="flex gap-4">
{quarterMonths.map((month) => (
<MonthGrid
key={month}
year={year}
month={month}
calendarType={calendarType}
onDayClick={handleDayClick}
/>
))}
</div>
</div>
{/* Navigation hint */}
<div className="px-4 pb-4">
<p className="text-xs text-center text-gray-400 dark:text-gray-500">
Click any day to zoom in Use to navigate quarters
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,216 @@
'use client'
import { useEffect, useRef, useMemo } from 'react'
import L from 'leaflet'
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents } from 'react-leaflet'
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
import { useMapState } from '@/hooks/useMapState'
import {
getSemanticLocationLabel,
SpatialGranularity,
SPATIAL_TO_LEAFLET_ZOOM,
GRANULARITY_LABELS,
leafletZoomToSpatial,
} from '@cal/shared'
import type { EventListItem, UnifiedEvent } from '@cal/shared'
import { clsx } from 'clsx'
// Fix Leaflet default marker icons for Next.js bundling
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: '/leaflet/marker-icon-2x.png',
iconUrl: '/leaflet/marker-icon.png',
shadowUrl: '/leaflet/marker-shadow.png',
})
interface SpatioTemporalMapProps {
events: EventListItem[]
}
/** Syncs the Leaflet map viewport with the Zustand store. */
function MapController({ center, zoom }: { center: [number, number]; zoom: number }) {
const map = useMap()
const isUserInteraction = useRef(false)
const { setSpatialGranularity, zoomCoupled } = useCalendarStore()
const prevCenter = useRef(center)
const prevZoom = useRef(zoom)
// Sync store → map (animate when the store changes)
useEffect(() => {
if (isUserInteraction.current) {
isUserInteraction.current = false
return
}
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1] || prevZoom.current !== zoom) {
map.flyTo(center, zoom, { duration: 0.8 })
prevCenter.current = center
prevZoom.current = zoom
}
}, [map, center, zoom])
// Sync map → store (when user manually zooms/pans)
useMapEvents({
zoomend: () => {
const mapZoom = map.getZoom()
if (Math.abs(mapZoom - zoom) > 0.5 && !zoomCoupled) {
isUserInteraction.current = true
setSpatialGranularity(leafletZoomToSpatial(mapZoom))
}
},
})
return null
}
/** Renders event markers, clustering at broad zooms. */
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 })[]
if (!isBroadZoom) {
// Fine zoom: individual markers
return eventsWithCoords.map((e) => ({
key: e.id,
lat: e.latitude,
lng: e.longitude,
label: e.title,
locationLabel: getSemanticLocationLabel(e, spatialGranularity),
color: e.source_color || '#3b82f6',
count: 1,
events: [e],
}))
}
// Broad zoom: cluster by semantic location
const groups = new Map<string, { events: (EventListItem & { latitude: number; longitude: number })[]; sumLat: number; sumLng: number }>()
for (const e of eventsWithCoords) {
const label = getSemanticLocationLabel(e, spatialGranularity)
const key = label || `${e.latitude.toFixed(1)},${e.longitude.toFixed(1)}`
const group = groups.get(key) || { events: [], sumLat: 0, sumLng: 0 }
group.events.push(e)
group.sumLat += e.latitude
group.sumLng += e.longitude
groups.set(key, group)
}
return Array.from(groups.entries()).map(([label, group]) => ({
key: label,
lat: group.sumLat / group.events.length,
lng: group.sumLng / group.events.length,
label,
locationLabel: label,
color: group.events[0].source_color || '#3b82f6',
count: group.events.length,
events: group.events,
}))
}, [events, spatialGranularity, isBroadZoom])
const markerRadius = isBroadZoom ? 12 : 6
return (
<>
{markers.map((marker) => (
<CircleMarker
key={marker.key}
center={[marker.lat, marker.lng]}
radius={marker.count > 1 ? Math.min(markerRadius + Math.log2(marker.count) * 3, 24) : markerRadius}
pathOptions={{
color: marker.color,
fillColor: marker.color,
fillOpacity: 0.7,
weight: 2,
}}
>
<Popup>
<div className="text-sm">
{marker.count > 1 ? (
<>
<div className="font-semibold mb-1">{marker.locationLabel}</div>
<div className="text-gray-500">{marker.count} events</div>
<ul className="mt-1 space-y-0.5 max-h-32 overflow-auto">
{marker.events.slice(0, 5).map((e) => (
<li key={e.id} className="text-xs">{e.title}</li>
))}
{marker.count > 5 && (
<li className="text-xs text-gray-400">+{marker.count - 5} more</li>
)}
</ul>
</>
) : (
<>
<div className="font-semibold">{marker.label}</div>
{marker.locationLabel && marker.locationLabel !== marker.label && (
<div className="text-xs text-gray-500">{marker.locationLabel}</div>
)}
{!marker.events[0].all_day && (
<div className="text-xs text-gray-500 mt-0.5">
{new Date(marker.events[0].start).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
})}
</div>
)}
</>
)}
</div>
</Popup>
</CircleMarker>
))}
</>
)
}
export function SpatioTemporalMap({ events }: SpatioTemporalMapProps) {
const { center, zoom } = useMapState(events)
const spatialGranularity = useEffectiveSpatialGranularity()
const { zoomCoupled, toggleZoomCoupled } = useCalendarStore()
return (
<div className="relative h-full w-full">
<MapContainer
center={center}
zoom={zoom}
className="h-full w-full"
zoomControl={false}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapController center={center} zoom={zoom} />
<EventMarkers events={events} />
</MapContainer>
{/* Overlay: granularity indicator + coupling toggle */}
<div className="absolute top-3 right-3 z-[1000] flex flex-col gap-2">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md px-3 py-2 text-xs">
<div className="text-gray-500 dark:text-gray-400">
{GRANULARITY_LABELS[spatialGranularity]}
</div>
</div>
<button
onClick={toggleZoomCoupled}
className={clsx(
'bg-white dark:bg-gray-800 rounded-lg shadow-md px-3 py-2 text-xs transition-colors',
zoomCoupled
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
)}
title={zoomCoupled ? 'Unlink spatial from temporal zoom' : 'Link spatial to temporal zoom'}
>
{zoomCoupled ? '🔗' : '🔓'}
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,27 @@
'use client'
import dynamic from 'next/dynamic'
import type { EventListItem } from '@cal/shared'
const SpatioTemporalMapInner = dynamic(
() => import('./SpatioTemporalMap').then((mod) => mod.SpatioTemporalMap),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="text-center">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<span className="text-sm text-gray-400">Loading map...</span>
</div>
</div>
),
}
)
interface Props {
events: EventListItem[]
}
export function SpatioTemporalMap({ events }: Props) {
return <SpatioTemporalMapInner events={events} />
}

View File

@ -0,0 +1,38 @@
'use client'
import { Panel, Group, Separator } from 'react-resizable-panels'
import { useCalendarStore } from '@/lib/store'
import { SpatioTemporalMap } from './SpatioTemporalMapLoader'
import type { EventListItem } from '@cal/shared'
interface SplitViewProps {
calendarContent: React.ReactNode
events: EventListItem[]
}
export function SplitView({ calendarContent, events }: SplitViewProps) {
const { mapVisible } = useCalendarStore()
if (!mapVisible) {
return <>{calendarContent}</>
}
return (
<Group orientation="horizontal" id="calendar-map-split">
{/* Calendar panel */}
<Panel defaultSize="60%" minSize="30%">
<div className="h-full overflow-auto p-4">
{calendarContent}
</div>
</Panel>
{/* Resize handle */}
<Separator className="w-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-blue-400 dark:hover:bg-blue-600 transition-colors cursor-col-resize" />
{/* Map panel */}
<Panel defaultSize="40%" minSize="20%">
<SpatioTemporalMap events={events} />
</Panel>
</Group>
)
}

View File

@ -0,0 +1,414 @@
'use client'
import { useCallback, useEffect } from 'react'
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
import {
TemporalGranularity,
TEMPORAL_GRANULARITY_LABELS,
SpatialGranularity,
GRANULARITY_LABELS,
} from '@cal/shared'
import { Link2, Unlink2 } from 'lucide-react'
import { clsx } from 'clsx'
// Zoom levels we support in the UI
const ZOOM_LEVELS: TemporalGranularity[] = [
TemporalGranularity.DAY,
TemporalGranularity.WEEK,
TemporalGranularity.MONTH,
TemporalGranularity.SEASON,
TemporalGranularity.YEAR,
TemporalGranularity.DECADE,
]
// Spatial levels for the filter (when uncoupled)
const SPATIAL_LEVELS: SpatialGranularity[] = [
SpatialGranularity.PLANET,
SpatialGranularity.CONTINENT,
SpatialGranularity.COUNTRY,
SpatialGranularity.CITY,
]
interface TemporalZoomControllerProps {
showSpatial?: boolean
compact?: boolean
}
export function TemporalZoomController({
showSpatial = true,
compact = false,
}: TemporalZoomControllerProps) {
const {
temporalGranularity,
setTemporalGranularity,
zoomIn,
zoomOut,
spatialGranularity,
setSpatialGranularity,
navigateByGranularity,
goToToday,
currentDate,
zoomCoupled,
toggleZoomCoupled,
} = useCalendarStore()
const effectiveSpatial = useEffectiveSpatialGranularity()
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
switch (e.key) {
case '+':
case '=':
e.preventDefault()
zoomIn()
break
case '-':
case '_':
e.preventDefault()
zoomOut()
break
case 'ArrowLeft':
if (!e.metaKey && !e.ctrlKey) {
e.preventDefault()
navigateByGranularity('prev')
}
break
case 'ArrowRight':
if (!e.metaKey && !e.ctrlKey) {
e.preventDefault()
navigateByGranularity('next')
}
break
case 't':
case 'T':
e.preventDefault()
goToToday()
break
case '1':
e.preventDefault()
setTemporalGranularity(TemporalGranularity.DAY)
break
case '2':
e.preventDefault()
setTemporalGranularity(TemporalGranularity.WEEK)
break
case '3':
e.preventDefault()
setTemporalGranularity(TemporalGranularity.MONTH)
break
case '4':
e.preventDefault()
setTemporalGranularity(TemporalGranularity.SEASON)
break
case '5':
e.preventDefault()
setTemporalGranularity(TemporalGranularity.YEAR)
break
case '6':
e.preventDefault()
setTemporalGranularity(TemporalGranularity.DECADE)
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [zoomIn, zoomOut, navigateByGranularity, goToToday, setTemporalGranularity])
// Wheel zoom
const handleWheel = useCallback(
(e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
if (e.deltaY < 0) {
zoomIn()
} else {
zoomOut()
}
}
},
[zoomIn, zoomOut]
)
useEffect(() => {
window.addEventListener('wheel', handleWheel, { passive: false })
return () => window.removeEventListener('wheel', handleWheel)
}, [handleWheel])
const currentZoomIndex = ZOOM_LEVELS.indexOf(temporalGranularity)
const canZoomIn = currentZoomIndex > 0
const canZoomOut = currentZoomIndex < ZOOM_LEVELS.length - 1
// Format current date based on granularity
const formatDate = () => {
const d = currentDate
switch (temporalGranularity) {
case TemporalGranularity.DAY:
return d.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
})
case TemporalGranularity.WEEK:
const weekStart = new Date(d)
weekStart.setDate(d.getDate() - d.getDay())
const weekEnd = new Date(weekStart)
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.MONTH:
return d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
case TemporalGranularity.YEAR:
return d.getFullYear().toString()
case TemporalGranularity.DECADE:
const decadeStart = Math.floor(d.getFullYear() / 10) * 10
return `${decadeStart}s`
default:
return d.toLocaleDateString()
}
}
if (compact) {
return (
<div className="flex items-center gap-2">
{/* Zoom buttons */}
<div className="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg">
<button
onClick={zoomIn}
disabled={!canZoomIn}
className={clsx(
'px-2 py-1 text-sm rounded-l-lg transition-colors',
canZoomIn
? 'hover:bg-gray-200 dark:hover:bg-gray-600'
: 'opacity-50 cursor-not-allowed'
)}
title="Zoom in (+ or Ctrl+Scroll)"
>
+
</button>
<span className="px-2 text-xs font-medium text-gray-600 dark:text-gray-300">
{TEMPORAL_GRANULARITY_LABELS[temporalGranularity]}
</span>
<button
onClick={zoomOut}
disabled={!canZoomOut}
className={clsx(
'px-2 py-1 text-sm rounded-r-lg transition-colors',
canZoomOut
? 'hover:bg-gray-200 dark:hover:bg-gray-600'
: 'opacity-50 cursor-not-allowed'
)}
title="Zoom out (- or Ctrl+Scroll)"
>
</button>
</div>
</div>
)
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
{/* Header with current context */}
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{formatDate()}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Viewing: {TEMPORAL_GRANULARITY_LABELS[temporalGranularity]}
</p>
</div>
<button
onClick={goToToday}
className="px-3 py-1.5 text-sm font-medium bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
Today
</button>
</div>
{/* Navigation */}
<div className="flex items-center justify-between mb-4">
<button
onClick={() => navigateByGranularity('prev')}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Previous (←)"
>
<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>
<div className="text-center">
<span className="text-sm text-gray-600 dark:text-gray-300">
Previous / Next
</span>
</div>
<button
onClick={() => navigateByGranularity('next')}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Next (→)"
>
<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>
</div>
{/* Temporal Zoom Slider */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Temporal Granularity
</label>
<div className="relative">
{/* Track */}
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full">
<div
className="h-2 bg-gradient-to-r from-blue-400 to-blue-600 rounded-full transition-all"
style={{
width: `${((currentZoomIndex + 1) / ZOOM_LEVELS.length) * 100}%`,
}}
/>
</div>
{/* Tick marks */}
<div className="flex justify-between mt-1">
{ZOOM_LEVELS.map((level, i) => (
<button
key={level}
onClick={() => setTemporalGranularity(level)}
className={clsx(
'flex flex-col items-center transition-all',
temporalGranularity === level
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
)}
>
<div
className={clsx(
'w-3 h-3 rounded-full border-2 -mt-2.5 bg-white dark:bg-gray-800 transition-all',
temporalGranularity === level
? 'border-blue-500 scale-125'
: 'border-gray-300 dark:border-gray-600'
)}
/>
<span className="text-xs mt-1">{TEMPORAL_GRANULARITY_LABELS[level]}</span>
</button>
))}
</div>
</div>
{/* Zoom buttons */}
<div className="flex items-center justify-center gap-4 mt-3">
<button
onClick={zoomIn}
disabled={!canZoomIn}
className={clsx(
'flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg transition-colors',
canZoomIn
? 'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
: 'bg-gray-50 dark:bg-gray-800 text-gray-400 cursor-not-allowed'
)}
>
<span className="text-lg">+</span>
<span>More Detail</span>
</button>
<button
onClick={zoomOut}
disabled={!canZoomOut}
className={clsx(
'flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg transition-colors',
canZoomOut
? 'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
: 'bg-gray-50 dark:bg-gray-800 text-gray-400 cursor-not-allowed'
)}
>
<span className="text-lg"></span>
<span>Less Detail</span>
</button>
</div>
</div>
{/* Coupling toggle */}
{showSpatial && (
<div className="flex items-center justify-center my-3">
<button
onClick={toggleZoomCoupled}
className={clsx(
'flex items-center gap-2 px-3 py-1.5 text-xs rounded-full transition-colors border',
zoomCoupled
? 'bg-blue-50 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400'
: 'bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-500'
)}
title={zoomCoupled ? 'Unlink spatial from temporal zoom (L)' : 'Link spatial to temporal zoom (L)'}
>
{zoomCoupled ? <Link2 className="w-3.5 h-3.5" /> : <Unlink2 className="w-3.5 h-3.5" />}
{zoomCoupled ? 'Zoom Coupled' : 'Zoom Independent'}
</button>
</div>
)}
{/* Spatial Granularity */}
{showSpatial && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Spatial Granularity
</label>
{zoomCoupled ? (
<div className="text-sm text-gray-500 dark:text-gray-400 px-1">
Auto: <span className="font-medium text-green-600 dark:text-green-400">{GRANULARITY_LABELS[effectiveSpatial]}</span>
<span className="text-xs ml-1">(driven by temporal zoom)</span>
</div>
) : (
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSpatialGranularity(null)}
className={clsx(
'px-3 py-1.5 text-xs rounded-full transition-colors',
spatialGranularity === null
? 'bg-green-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
)}
>
All Locations
</button>
{SPATIAL_LEVELS.map((level) => (
<button
key={level}
onClick={() => setSpatialGranularity(level)}
className={clsx(
'px-3 py-1.5 text-xs rounded-full transition-colors',
spatialGranularity === level
? 'bg-green-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
)}
>
{GRANULARITY_LABELS[level]}
</button>
))}
</div>
)}
</div>
)}
{/* Keyboard shortcuts hint */}
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-400 dark:text-gray-500 text-center">
Keyboard: <kbd className="px-1 bg-gray-100 dark:bg-gray-700 rounded">+</kbd>/<kbd className="px-1 bg-gray-100 dark:bg-gray-700 rounded">-</kbd> zoom {' '}
<kbd className="px-1 bg-gray-100 dark:bg-gray-700 rounded"></kbd>/<kbd className="px-1 bg-gray-100 dark:bg-gray-700 rounded"></kbd> navigate {' '}
<kbd className="px-1 bg-gray-100 dark:bg-gray-700 rounded">t</kbd> today {' '}
<kbd className="px-1 bg-gray-100 dark:bg-gray-700 rounded">1-6</kbd> granularity {' '}
<kbd className="px-1 bg-gray-100 dark:bg-gray-700 rounded">m</kbd> map {' '}
<kbd className="px-1 bg-gray-100 dark:bg-gray-700 rounded">l</kbd> link
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,718 @@
'use client'
import { useMemo, useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useCalendarStore } from '@/lib/store'
import {
gregorianToIFC,
getIFCMonthColor,
isLeapYear,
IFC_MONTHS,
TemporalGranularity,
} from '@cal/shared'
import { clsx } from 'clsx'
type ViewMode = 'compact' | 'glance'
// Vibrant month colors for Gregorian calendar
const MONTH_COLORS = [
'#3B82F6', // January - Blue
'#EC4899', // February - Pink
'#10B981', // March - Emerald
'#F59E0B', // April - Amber
'#84CC16', // May - Lime
'#F97316', // June - Orange
'#EF4444', // July - Red
'#8B5CF6', // August - Violet
'#14B8A6', // September - Teal
'#D97706', // October - Amber Dark
'#A855F7', // November - Purple
'#0EA5E9', // December - Sky Blue
]
// Compact mini-month for the grid overview
interface MiniMonthProps {
year: number
month: number
calendarType: 'gregorian' | 'ifc'
isCurrentMonth: boolean
onMonthClick: (month: number) => void
eventCounts?: Record<number, number>
}
function MiniMonth({
year,
month,
calendarType,
isCurrentMonth,
onMonthClick,
eventCounts = {},
}: MiniMonthProps) {
const monthData = useMemo(() => {
if (calendarType === 'ifc') {
const days: { day: number; isToday: boolean }[] = []
const today = gregorianToIFC(new Date())
for (let day = 1; day <= 28; day++) {
days.push({
day,
isToday: today.year === year && today.month === month && today.day === day,
})
}
return { days, startDay: 0 }
} else {
const firstDay = new Date(year, month - 1, 1)
const lastDay = new Date(year, month, 0)
const daysInMonth = lastDay.getDate()
const startDay = firstDay.getDay()
const today = new Date()
today.setHours(0, 0, 0, 0)
const days: { day: number; isToday: boolean }[] = []
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month - 1, day)
days.push({
day,
isToday: date.getTime() === today.getTime(),
})
}
return { days, startDay }
}
}, [year, month, calendarType])
const monthName = calendarType === 'ifc'
? IFC_MONTHS[month - 1]
: new Date(year, month - 1).toLocaleDateString('en-US', { month: 'short' })
const monthColor = calendarType === 'ifc' ? getIFCMonthColor(month) : MONTH_COLORS[month - 1]
const maxEvents = Math.max(...Object.values(eventCounts), 1)
const getHeatColor = (count: number) => {
if (count === 0) return undefined
const intensity = Math.min(count / maxEvents, 1)
return `rgba(59, 130, 246, ${0.2 + intensity * 0.6})`
}
return (
<div
onClick={() => onMonthClick(month)}
className={clsx(
'p-2 rounded-lg cursor-pointer transition-all duration-200',
'hover:scale-105 hover:shadow-lg',
isCurrentMonth
? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700'
)}
style={{
borderTop: `3px solid ${monthColor}`,
}}
>
<div
className="text-xs font-semibold mb-1 text-center"
style={{ color: monthColor }}
>
{monthName}
</div>
<div className="grid grid-cols-7 gap-px mb-0.5">
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
<div key={`${month}-header-${i}`} className="text-[8px] text-gray-400 text-center">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-px">
{Array.from({ length: monthData.startDay }).map((_, i) => (
<div key={`${month}-empty-${i}`} className="w-4 h-4" />
))}
{monthData.days.map(({ day, isToday }) => {
const eventCount = eventCounts[day] || 0
return (
<div
key={`${month}-day-${day}`}
className={clsx(
'w-4 h-4 text-[9px] flex items-center justify-center rounded-sm',
isToday
? 'bg-blue-500 text-white font-bold'
: 'text-gray-600 dark:text-gray-400'
)}
style={{
backgroundColor: isToday ? undefined : getHeatColor(eventCount),
}}
title={eventCount > 0 ? `${eventCount} event${eventCount > 1 ? 's' : ''}` : undefined}
>
{day}
</div>
)
})}
</div>
</div>
)
}
// Convert Sunday=0 to Monday=0 indexing
function toMondayFirst(dayOfWeek: number): number {
return dayOfWeek === 0 ? 6 : dayOfWeek - 1
}
// 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
interface GlanceMonthColumnProps {
year: number
month: number
calendarType: 'gregorian' | 'ifc'
onDayClick?: (date: Date) => void
}
type CalendarSlot = {
type: 'day'
day: number
date: Date
isToday: boolean
dayOfWeek: number // Monday=0, Sunday=6
} | {
type: 'empty'
dayOfWeek: number
}
function GlanceMonthColumn({ year, month, calendarType, onDayClick }: GlanceMonthColumnProps) {
const today = new Date()
today.setHours(0, 0, 0, 0)
// Build a 6-week (42 slot) grid aligned by weekday
const calendarGrid = useMemo(() => {
const slots: CalendarSlot[] = []
if (calendarType === 'ifc') {
// IFC: Always 28 days, always starts on Sunday (which is day 6 in Monday-first)
const todayIFC = gregorianToIFC(today)
// IFC months always start on Sunday, which is position 6 in Monday-first
// Add 6 empty slots for Mon-Sat before Sunday
for (let i = 0; i < 6; i++) {
slots.push({ type: 'empty', dayOfWeek: i })
}
for (let day = 1; day <= 28; day++) {
const ifcDayOfYear = (month - 1) * 28 + day
let gregorianDayOfYear = ifcDayOfYear
if (isLeapYear(year) && ifcDayOfYear >= 169) {
gregorianDayOfYear += 1
}
const date = new Date(year, 0, gregorianDayOfYear)
const dayOfWeek = (day - 1) % 7 // IFC: 0=Sun, 1=Mon... but we need Monday-first
const mondayFirst = dayOfWeek === 0 ? 6 : dayOfWeek - 1
slots.push({
type: 'day',
day,
date,
isToday: todayIFC.year === year && todayIFC.month === month && todayIFC.day === day,
dayOfWeek: mondayFirst,
})
}
// IFC 28 days + 6 leading = 34 slots, pad to 35 (5 weeks)
while (slots.length < 35) {
slots.push({ type: 'empty', dayOfWeek: slots.length % 7 })
}
} else {
// 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()
const startDayOfWeek = toMondayFirst(firstDay.getDay())
// Add empty slots before the 1st
for (let i = 0; i < startDayOfWeek; i++) {
slots.push({ type: 'empty', dayOfWeek: i })
}
// Add all days of the month
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month - 1, day)
slots.push({
type: 'day',
day,
date,
isToday: date.getTime() === today.getTime(),
dayOfWeek: toMondayFirst(date.getDay()),
})
}
// Pad to complete the final week (to 35 or 42 slots for 5-6 weeks)
const targetSlots = slots.length <= 35 ? 35 : 42
while (slots.length < targetSlots) {
slots.push({ type: 'empty', dayOfWeek: slots.length % 7 })
}
}
return slots
}, [year, month, calendarType, today])
const monthName = calendarType === 'ifc'
? IFC_MONTHS[month - 1]
: new Date(year, month - 1).toLocaleDateString('en-US', { month: 'short' })
const monthColor = calendarType === 'ifc' ? getIFCMonthColor(month) : MONTH_COLORS[month - 1]
return (
<div className="flex flex-col flex-1 min-w-[7rem]">
{/* Month header */}
<div
className="text-center py-2 font-bold text-white flex-shrink-0"
style={{ backgroundColor: monthColor }}
>
<div className="text-base">{monthName}</div>
</div>
{/* Days grid - aligned by weekday */}
<div
className="flex-1 flex flex-col border-x border-b overflow-hidden"
style={{ borderColor: `${monthColor}40` }}
>
{calendarGrid.map((slot, idx) => {
const isWeekend = slot.dayOfWeek >= 5 // Saturday=5, Sunday=6
const isSunday = slot.dayOfWeek === 6
const isMonday = slot.dayOfWeek === 0
if (slot.type === 'empty') {
return (
<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',
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',
isMonday && 'border-t-2 border-t-gray-200 dark:border-t-gray-600'
)}
style={{ borderColor: `${monthColor}20` }}
>
<span className="text-[10px] font-medium w-5 flex-shrink-0 text-gray-300 dark:text-gray-600">
{WEEKDAY_LABELS_MONDAY_FIRST[slot.dayOfWeek]}
</span>
</div>
)
}
return (
<button
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',
'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',
isWeekend && !isSunday && !slot.isToday && 'bg-blue-50 dark:bg-blue-900/20',
!isWeekend && !slot.isToday && 'bg-white dark:bg-gray-900',
isMonday && 'border-t-2 border-t-gray-200 dark:border-t-gray-600'
)}
style={{
borderColor: `${monthColor}20`,
...(slot.isToday && {
backgroundColor: monthColor,
color: 'white',
ringColor: 'white',
}),
}}
>
<span className={clsx(
'text-[10px] font-medium w-5 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'
)}>
{WEEKDAY_LABELS_MONDAY_FIRST[slot.dayOfWeek]}
</span>
<span className={clsx(
'text-sm font-semibold w-5 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>
)
})}
</div>
</div>
)
}
// Special day card for IFC in Glance view
function GlanceSpecialDay({ type, year, color }: { type: 'year-day' | 'leap-day'; year: number; color: string }) {
const isYearDay = type === 'year-day'
const today = new Date()
const isToday = isYearDay
? today.getMonth() === 11 && today.getDate() === 31 && today.getFullYear() === year
: isLeapYear(year) && today.getMonth() === 5 && today.getDate() === 17 && today.getFullYear() === year
return (
<div className="flex flex-col min-w-[5rem]">
<div
className="text-center py-2 font-bold text-white flex-shrink-0"
style={{ backgroundColor: color }}
>
<div className="text-sm">{isYearDay ? '✨' : '🌟'}</div>
</div>
<div
className={clsx(
'flex-1 border-x border-b flex items-center justify-center p-3',
isToday && 'ring-2 ring-amber-500'
)}
style={{
borderColor: `${color}40`,
backgroundColor: isToday ? color : `${color}10`,
}}
>
<div className="text-center">
<div className={clsx(
'text-sm font-bold',
isToday ? 'text-white' : 'text-gray-800 dark:text-gray-200'
)}>
{isYearDay ? 'Year Day' : 'Leap Day'}
</div>
<div className={clsx(
'text-xs mt-1',
isToday ? 'text-white/80' : 'text-gray-500'
)}>
{isYearDay ? 'Dec 31' : 'Jun 17'}
</div>
</div>
</div>
</div>
)
}
// Special day cards for IFC in Compact view
function SpecialDayCard({ type, year }: { type: 'year-day' | 'leap-day'; year: number }) {
const isYearDay = type === 'year-day'
const today = new Date()
const isToday = isYearDay
? today.getMonth() === 11 && today.getDate() === 31 && today.getFullYear() === year
: isLeapYear(year) && today.getMonth() === 5 && today.getDate() === 17 && today.getFullYear() === year
return (
<div
className={clsx(
'p-2 rounded-lg text-center',
isToday
? 'ring-2 ring-amber-500 bg-amber-50 dark:bg-amber-900/20'
: 'bg-gradient-to-br from-amber-100 to-orange-100 dark:from-amber-900/30 dark:to-orange-900/30'
)}
>
<div className="text-xs font-bold text-amber-700 dark:text-amber-400">
{isYearDay ? 'Year Day' : 'Leap Day'}
</div>
<div className="text-[10px] text-amber-600 dark:text-amber-500 mt-0.5">
{isYearDay ? 'Dec 31' : 'Jun 17'}
</div>
</div>
)
}
// Fullscreen Glance View Portal
interface FullscreenGlanceProps {
year: number
months: number
calendarType: 'gregorian' | 'ifc'
leap: boolean
onDayClick: (date: Date) => void
onClose: () => void
navigatePrev: () => void
navigateNext: () => void
}
function FullscreenGlance({
year,
months,
calendarType,
leap,
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">
{calendarType === 'ifc' ? 'International Fixed Calendar' : '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: months }).map((_, i) => (
<GlanceMonthColumn
key={i + 1}
year={year}
month={i + 1}
calendarType={calendarType}
onDayClick={onDayClick}
/>
))}
{/* IFC Special days */}
{calendarType === 'ifc' && (
<>
<GlanceSpecialDay type="year-day" year={year} color="#F59E0B" />
{leap && <GlanceSpecialDay type="leap-day" year={year} color="#8B5CF6" />}
</>
)}
</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 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 {
currentDate,
calendarType,
setCurrentDate,
setViewType,
setTemporalGranularity,
navigateByGranularity,
} = useCalendarStore()
const year = currentDate.getFullYear()
const currentMonth = currentDate.getMonth() + 1
const currentIFC = gregorianToIFC(currentDate)
const handleMonthClick = (month: number) => {
if (calendarType === 'ifc') {
const ifcDayOfYear = (month - 1) * 28 + 1
let gregorianDayOfYear = ifcDayOfYear
if (isLeapYear(year) && ifcDayOfYear >= 169) {
gregorianDayOfYear += 1
}
const targetDate = new Date(year, 0, gregorianDayOfYear)
setCurrentDate(targetDate)
} else {
setCurrentDate(new Date(year, month - 1, 1))
}
setTemporalGranularity(TemporalGranularity.MONTH)
setViewType('month')
}
const handleDayClick = (date: Date) => {
setCurrentDate(date)
setTemporalGranularity(TemporalGranularity.DAY)
}
const months = calendarType === 'ifc' ? 13 : 12
const leap = isLeapYear(year)
// Mock event counts for compact view
const mockEventCounts = useMemo(() => {
const counts: Record<number, Record<number, number>> = {}
for (let m = 1; m <= months; m++) {
counts[m] = {}
for (let d = 1; d <= 28; d++) {
if (Math.random() > 0.7) {
counts[m][d] = Math.floor(Math.random() * 5) + 1
}
}
}
return counts
}, [months])
// Glance mode uses fullscreen 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>
</div>
{/* Fullscreen portal */}
<FullscreenGlance
year={year}
months={months}
calendarType={calendarType}
leap={leap}
onDayClick={handleDayClick}
onClose={() => setViewMode('compact')}
navigatePrev={() => navigateByGranularity('prev')}
navigateNext={() => navigateByGranularity('next')}
/>
</>
)
}
// 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">
{/* Year header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{year}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{calendarType === 'ifc'
? `International Fixed Calendar • ${months} months`
: 'Gregorian Calendar'
}
</p>
</div>
{/* View mode toggle */}
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<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"
>
Compact
</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"
>
Fullscreen
</button>
</div>
</div>
{/* Month grid */}
<div className="p-4">
<div
className={clsx(
'grid gap-3',
calendarType === 'ifc' ? 'grid-cols-4' : 'grid-cols-4 md:grid-cols-6'
)}
>
{Array.from({ length: months }).map((_, i) => {
const month = i + 1
const isCurrentMonthView = calendarType === 'ifc'
? currentIFC.month === month && currentIFC.year === year
: currentMonth === month && currentDate.getFullYear() === year
return (
<MiniMonth
key={`month-${month}`}
year={year}
month={month}
calendarType={calendarType}
isCurrentMonth={isCurrentMonthView}
onMonthClick={handleMonthClick}
eventCounts={mockEventCounts[month]}
/>
)
})}
{calendarType === 'ifc' && (
<>
<SpecialDayCard type="year-day" year={year} />
{leap && <SpecialDayCard type="leap-day" year={year} />}
</>
)}
</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>
</div>
</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

@ -0,0 +1,8 @@
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 { SplitView } from './SplitView'
export { SpatioTemporalMap } from './SpatioTemporalMapLoader'

50
src/hooks/useMapState.ts Normal file
View File

@ -0,0 +1,50 @@
'use client'
import { useMemo } from 'react'
import { useEffectiveSpatialGranularity } from '@/lib/store'
import { SPATIAL_TO_LEAFLET_ZOOM, type MapState, type EventListItem } from '@cal/shared'
const WORLD_CENTER: [number, number] = [30, 0]
interface EventWithCoords {
latitude?: number | null
longitude?: number | null
coordinates?: { latitude: number; longitude: number } | null
}
/**
* Computes map center and zoom from events and the current spatial granularity.
* Center is the centroid of all events with coordinates.
* Zoom is derived from the effective spatial granularity.
*/
export function useMapState(events: (EventListItem & EventWithCoords)[]): MapState {
const spatialGranularity = useEffectiveSpatialGranularity()
const center = useMemo<[number, number]>(() => {
const withCoords = events.filter((e) => {
if (e.coordinates) return true
if ('latitude' in e && e.latitude != null && 'longitude' in e && e.longitude != null) return true
return false
})
if (withCoords.length === 0) return WORLD_CENTER
let sumLat = 0
let sumLng = 0
for (const e of withCoords) {
if (e.coordinates) {
sumLat += e.coordinates.latitude
sumLng += e.coordinates.longitude
} else if ('latitude' in e && e.latitude != null && 'longitude' in e && e.longitude != null) {
sumLat += e.latitude
sumLng += e.longitude
}
}
return [sumLat / withCoords.length, sumLng / withCoords.length]
}, [events])
const zoom = SPATIAL_TO_LEAFLET_ZOOM[spatialGranularity]
return { center, zoom }
}

266
src/lib/store.ts Normal file
View File

@ -0,0 +1,266 @@
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { CalendarType, ViewType, Location, SpatialGranularity } from '@cal/shared'
import { TemporalGranularity, TEMPORAL_TO_VIEW, TEMPORAL_TO_SPATIAL } from '@cal/shared'
interface CalendarState {
// Calendar type
calendarType: CalendarType
setCalendarType: (type: CalendarType) => void
toggleCalendarType: () => void
// View type
viewType: ViewType
setViewType: (type: ViewType) => void
// Temporal granularity (zoom level)
temporalGranularity: TemporalGranularity
setTemporalGranularity: (granularity: TemporalGranularity) => void
zoomIn: () => void // More detail (Year → Month → Week → Day)
zoomOut: () => void // Less detail (Day → Week → Month → Year)
// Spatial granularity filter
spatialGranularity: SpatialGranularity | null
setSpatialGranularity: (granularity: SpatialGranularity | null) => void
// Current date
currentDate: Date
setCurrentDate: (date: Date) => void
goToToday: () => void
goToNextMonth: () => void
goToPreviousMonth: () => void
goToNextYear: () => void
goToPreviousYear: () => void
goToNextDecade: () => void
goToPreviousDecade: () => void
navigateByGranularity: (direction: 'next' | 'prev') => void
// Selected event
selectedEventId: string | null
setSelectedEventId: (id: string | null) => void
// Location filter
selectedLocation: Location | null
setSelectedLocation: (location: Location | null) => void
// Source filters (hiddenSources: sources to hide; empty = show all)
hiddenSources: string[]
toggleSourceVisibility: (sourceId: string) => void
setHiddenSources: (sources: string[]) => void
isSourceVisible: (sourceId: string) => boolean
// UI state
showIFCDates: boolean
setShowIFCDates: (show: boolean) => void
// Map panel
mapVisible: boolean
setMapVisible: (visible: boolean) => void
toggleMap: () => void
// Coupled zoom (temporal drives spatial)
zoomCoupled: boolean
setZoomCoupled: (coupled: boolean) => void
toggleZoomCoupled: () => void
}
export const useCalendarStore = create<CalendarState>()(
persist(
(set, get) => ({
// Calendar type
calendarType: 'gregorian',
setCalendarType: (calendarType) => set({ calendarType }),
toggleCalendarType: () =>
set((state) => ({
calendarType: state.calendarType === 'gregorian' ? 'ifc' : 'gregorian',
})),
// View type
viewType: 'month',
setViewType: (viewType) => set({ viewType }),
// Temporal granularity
temporalGranularity: TemporalGranularity.MONTH,
setTemporalGranularity: (granularity) => {
const view = TEMPORAL_TO_VIEW[granularity]
const coupled = get().zoomCoupled
set({
temporalGranularity: granularity,
...(view && { viewType: view }),
...(coupled && { spatialGranularity: TEMPORAL_TO_SPATIAL[granularity] }),
})
},
zoomIn: () =>
set((state) => {
const newGranularity = Math.max(
TemporalGranularity.DAY,
state.temporalGranularity - 1
) as TemporalGranularity
const view = TEMPORAL_TO_VIEW[newGranularity]
return {
temporalGranularity: newGranularity,
...(view && { viewType: view }),
...(state.zoomCoupled && { spatialGranularity: TEMPORAL_TO_SPATIAL[newGranularity] }),
}
}),
zoomOut: () =>
set((state) => {
const newGranularity = Math.min(
TemporalGranularity.DECADE,
state.temporalGranularity + 1
) as TemporalGranularity
const view = TEMPORAL_TO_VIEW[newGranularity]
return {
temporalGranularity: newGranularity,
...(view && { viewType: view }),
...(state.zoomCoupled && { spatialGranularity: TEMPORAL_TO_SPATIAL[newGranularity] }),
}
}),
// Spatial granularity
spatialGranularity: null,
setSpatialGranularity: (spatialGranularity) => set({ spatialGranularity }),
// Current date
currentDate: new Date(),
setCurrentDate: (currentDate) => set({ currentDate }),
goToToday: () => set({ currentDate: new Date() }),
goToNextMonth: () =>
set((state) => {
const next = new Date(state.currentDate)
next.setMonth(next.getMonth() + 1)
return { currentDate: next }
}),
goToPreviousMonth: () =>
set((state) => {
const prev = new Date(state.currentDate)
prev.setMonth(prev.getMonth() - 1)
return { currentDate: prev }
}),
goToNextYear: () =>
set((state) => {
const next = new Date(state.currentDate)
next.setFullYear(next.getFullYear() + 1)
return { currentDate: next }
}),
goToPreviousYear: () =>
set((state) => {
const prev = new Date(state.currentDate)
prev.setFullYear(prev.getFullYear() - 1)
return { currentDate: prev }
}),
goToNextDecade: () =>
set((state) => {
const next = new Date(state.currentDate)
next.setFullYear(next.getFullYear() + 10)
return { currentDate: next }
}),
goToPreviousDecade: () =>
set((state) => {
const prev = new Date(state.currentDate)
prev.setFullYear(prev.getFullYear() - 10)
return { currentDate: prev }
}),
navigateByGranularity: (direction) =>
set((state) => {
const current = new Date(state.currentDate)
const delta = direction === 'next' ? 1 : -1
switch (state.temporalGranularity) {
case TemporalGranularity.DAY:
current.setDate(current.getDate() + delta)
break
case TemporalGranularity.WEEK:
current.setDate(current.getDate() + delta * 7)
break
case TemporalGranularity.MONTH:
current.setMonth(current.getMonth() + delta)
break
case TemporalGranularity.SEASON:
current.setMonth(current.getMonth() + delta * 3)
break
case TemporalGranularity.YEAR:
current.setFullYear(current.getFullYear() + delta)
break
case TemporalGranularity.DECADE:
current.setFullYear(current.getFullYear() + delta * 10)
break
case TemporalGranularity.CENTURY:
current.setFullYear(current.getFullYear() + delta * 100)
break
default:
current.setDate(current.getDate() + delta)
}
return { currentDate: current }
}),
// Selected event
selectedEventId: null,
setSelectedEventId: (selectedEventId) => set({ selectedEventId }),
// Location filter
selectedLocation: null,
setSelectedLocation: (selectedLocation) => set({ selectedLocation }),
// Source filters (hiddenSources: sources to hide; empty = show all)
hiddenSources: [],
toggleSourceVisibility: (sourceId) =>
set((state) => ({
hiddenSources: state.hiddenSources.includes(sourceId)
? state.hiddenSources.filter((id) => id !== sourceId)
: [...state.hiddenSources, sourceId],
})),
setHiddenSources: (hiddenSources) => set({ hiddenSources }),
isSourceVisible: (sourceId) => !get().hiddenSources.includes(sourceId),
// UI state
showIFCDates: true,
setShowIFCDates: (showIFCDates) => set({ showIFCDates }),
// Map panel
mapVisible: true,
setMapVisible: (mapVisible) => set({ mapVisible }),
toggleMap: () => set((state) => ({ mapVisible: !state.mapVisible })),
// Coupled zoom
zoomCoupled: true,
setZoomCoupled: (zoomCoupled) => set({ zoomCoupled }),
toggleZoomCoupled: () =>
set((state) => {
const newCoupled = !state.zoomCoupled
return {
zoomCoupled: newCoupled,
// When re-coupling, sync spatial to current temporal
...(newCoupled && { spatialGranularity: TEMPORAL_TO_SPATIAL[state.temporalGranularity] }),
}
}),
}),
{
name: 'calendar-store',
version: 2,
partialize: (state) => ({
calendarType: state.calendarType,
viewType: state.viewType,
temporalGranularity: state.temporalGranularity,
spatialGranularity: state.spatialGranularity,
showIFCDates: state.showIFCDates,
hiddenSources: state.hiddenSources,
mapVisible: state.mapVisible,
zoomCoupled: state.zoomCoupled,
}),
}
)
)
/** Selector: returns the effective spatial granularity (coupled or manual). */
export function useEffectiveSpatialGranularity(): SpatialGranularity {
return useCalendarStore((state) => {
if (state.zoomCoupled) {
return TEMPORAL_TO_SPATIAL[state.temporalGranularity]
}
return state.spatialGranularity ?? TEMPORAL_TO_SPATIAL[TemporalGranularity.MONTH]
})
}

38
tailwind.config.ts Normal file
View File

@ -0,0 +1,38 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class',
theme: {
extend: {
colors: {
// IFC Calendar colors (one per month)
ifc: {
january: '#ef4444', // Red
february: '#f97316', // Orange
march: '#eab308', // Yellow
april: '#22c55e', // Green
may: '#14b8a6', // Teal
june: '#0ea5e9', // Sky
sol: '#f59e0b', // Amber (special month)
july: '#3b82f6', // Blue
august: '#6366f1', // Indigo
september: '#8b5cf6', // Violet
october: '#a855f7', // Purple
november: '#ec4899', // Pink
december: '#78716c', // Stone
},
},
fontFamily: {
mono: ['JetBrains Mono', 'monospace'],
},
},
},
plugins: [],
}
export default config

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}