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:
commit
0723957dca
|
|
@ -0,0 +1,5 @@
|
|||
# API Configuration
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||
|
||||
# Build mode
|
||||
BUILD_STANDALONE=false
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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='© <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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 "Fullscreen" for year-at-a-glance
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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]
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue