feat: initial rcal-online — semantic calendar engine with lunar overlay
Forked from zoomcal-jeffemmett, unified with cal-jeffemmett into a single semantically-aware calendar app. Replaces IFC calendar with lunar calendar, adds 4-tab views (Temporal, Spatial, Lunar, Context), and r* tool integration layer for cross-tool calendar rendering. - Phase 0: Forked zoomcal, inlined @cal/shared, added lunarphase-js + suncalc - Phase 1: New type system (lunar + r* tools), Zustand store with tabs, IFC stripped - Phase 2: Lunar engine with synodic month tracking, MoonPhaseIcon, LunarTab - Phase 3: Adapter registry for r* tools, RCalProvider, URL-driven /context route - Phase 4: Prisma + PostgreSQL backend, 7 API routes (events, sources, lunar, context) - Phase 5: Docker multi-stage build, hardened docker-compose with PostgreSQL 16 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
5fcbfd6fa8
|
|
@ -0,0 +1,10 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Database
|
||||
DATABASE_URL=postgresql://rcal:rcal_password@localhost:5432/rcal
|
||||
|
||||
# Next.js
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
|
@ -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,52 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy prisma schema and generate client
|
||||
COPY prisma ./prisma/
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
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
|
||||
|
||||
# Copy prisma for migrations
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
services:
|
||||
rcal:
|
||||
build: .
|
||||
container_name: rcal-online
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://rcal:${POSTGRES_PASSWORD}@rcal-postgres:5432/rcal
|
||||
depends_on:
|
||||
rcal-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.rcal.rule=Host(`rcal.jeffemmett.com`)"
|
||||
- "traefik.http.routers.rcal.entrypoints=web"
|
||||
- "traefik.http.services.rcal.loadbalancer.server.port=3000"
|
||||
networks:
|
||||
- traefik-public
|
||||
- rcal-internal
|
||||
|
||||
rcal-postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: rcal-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=rcal
|
||||
- POSTGRES_USER=rcal
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- rcal-pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U rcal -d rcal"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- DAC_OVERRIDE
|
||||
- FOWNER
|
||||
- SETGID
|
||||
- SETUID
|
||||
networks:
|
||||
- rcal-internal
|
||||
|
||||
volumes:
|
||||
rcal-pgdata:
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
rcal-internal:
|
||||
driver: bridge
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "rcal-online",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.3.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.312.0",
|
||||
"lunarphase-js": "^2.0.0",
|
||||
"nanoid": "^5.0.0",
|
||||
"next": "^14.2.0",
|
||||
"prisma": "^6.19.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-resizable-panels": "^4.6.2",
|
||||
"suncalc": "^1.9.0",
|
||||
"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",
|
||||
"@types/suncalc": "^1.9.2",
|
||||
"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,102 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(cuid())
|
||||
sourceId String @map("source_id")
|
||||
externalId String @default("") @map("external_id")
|
||||
title String
|
||||
description String @default("")
|
||||
start DateTime
|
||||
end DateTime
|
||||
allDay Boolean @default(false) @map("all_day")
|
||||
timezoneStr String @default("UTC") @map("timezone_str")
|
||||
rrule String @default("")
|
||||
isRecurring Boolean @default(false) @map("is_recurring")
|
||||
|
||||
// Location
|
||||
locationRaw String @default("") @map("location_raw")
|
||||
locationId String? @map("location_id")
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
isVirtual Boolean @default(false) @map("is_virtual")
|
||||
virtualUrl String @default("") @map("virtual_url")
|
||||
virtualPlatform String @default("") @map("virtual_platform")
|
||||
|
||||
// Participants
|
||||
organizerName String @default("") @map("organizer_name")
|
||||
organizerEmail String @default("") @map("organizer_email")
|
||||
attendees Json @default("[]")
|
||||
attendeeCount Int @default(0) @map("attendee_count")
|
||||
|
||||
// Status
|
||||
status String @default("confirmed")
|
||||
visibility String @default("default")
|
||||
|
||||
// r* tool integration
|
||||
rToolSource String? @map("r_tool_source")
|
||||
rToolEntityId String? @map("r_tool_entity_id")
|
||||
eventCategory String? @map("event_category")
|
||||
metadata Json?
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
source CalendarSource @relation(fields: [sourceId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id])
|
||||
|
||||
@@index([sourceId])
|
||||
@@index([start, end])
|
||||
@@index([rToolSource, rToolEntityId])
|
||||
@@index([locationId])
|
||||
@@map("events")
|
||||
}
|
||||
|
||||
model CalendarSource {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
sourceType String @map("source_type")
|
||||
color String @default("#6b7280")
|
||||
isVisible Boolean @default(true) @map("is_visible")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
lastSyncedAt DateTime? @map("last_synced_at")
|
||||
syncError String @default("") @map("sync_error")
|
||||
syncConfig Json? @map("sync_config")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
events Event[]
|
||||
|
||||
@@map("calendar_sources")
|
||||
}
|
||||
|
||||
model Location {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
granularity Int @default(0)
|
||||
parentId String? @map("parent_id")
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
timezoneStr String @default("UTC") @map("timezone_str")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Self-relation
|
||||
parent Location? @relation("LocationHierarchy", fields: [parentId], references: [id])
|
||||
children Location[] @relation("LocationHierarchy")
|
||||
events Event[]
|
||||
|
||||
@@index([parentId])
|
||||
@@map("locations")
|
||||
}
|
||||
|
|
@ -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,70 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { RToolName } from '@/lib/types'
|
||||
|
||||
const VALID_TOOLS: RToolName[] = ['rTrips', 'rNetwork', 'rMaps', 'rCart', 'rNotes', 'standalone']
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { tool: string } }
|
||||
) {
|
||||
const tool = params.tool as RToolName
|
||||
|
||||
if (!VALID_TOOLS.includes(tool)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid tool: ${tool}. Valid: ${VALID_TOOLS.join(', ')}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl
|
||||
const entityId = searchParams.get('entity_id')
|
||||
const start = searchParams.get('start')
|
||||
const end = searchParams.get('end')
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
rToolSource: tool,
|
||||
}
|
||||
|
||||
if (entityId) {
|
||||
where.rToolEntityId = entityId
|
||||
}
|
||||
if (start) {
|
||||
where.start = { gte: new Date(start) }
|
||||
}
|
||||
if (end) {
|
||||
where.end = { lte: new Date(end) }
|
||||
}
|
||||
|
||||
try {
|
||||
const events = await prisma.event.findMany({
|
||||
where,
|
||||
include: {
|
||||
source: { select: { id: true, name: true, color: true, sourceType: true } },
|
||||
},
|
||||
orderBy: { start: 'asc' },
|
||||
take: 500,
|
||||
})
|
||||
|
||||
const results = events.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.sourceId,
|
||||
source_name: e.source.name,
|
||||
source_color: e.source.color,
|
||||
title: e.title,
|
||||
description: e.description,
|
||||
start: e.start.toISOString(),
|
||||
end: e.end.toISOString(),
|
||||
all_day: e.allDay,
|
||||
r_tool_source: e.rToolSource,
|
||||
r_tool_entity_id: e.rToolEntityId,
|
||||
event_category: e.eventCategory,
|
||||
metadata: e.metadata,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ tool, count: results.length, results })
|
||||
} catch (err) {
|
||||
console.error('Context API error:', err)
|
||||
return NextResponse.json({ error: 'Failed to fetch context events' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
source: { select: { id: true, name: true, color: true, sourceType: true } },
|
||||
location: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: 'Event not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(event)
|
||||
} catch (err) {
|
||||
console.error('Event GET error:', err)
|
||||
return NextResponse.json({ error: 'Failed to fetch event' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const event = await prisma.event.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
...(body.title !== undefined && { title: body.title }),
|
||||
...(body.description !== undefined && { description: body.description }),
|
||||
...(body.start !== undefined && { start: new Date(body.start) }),
|
||||
...(body.end !== undefined && { end: new Date(body.end) }),
|
||||
...(body.all_day !== undefined && { allDay: body.all_day }),
|
||||
...(body.location_raw !== undefined && { locationRaw: body.location_raw }),
|
||||
...(body.latitude !== undefined && { latitude: body.latitude }),
|
||||
...(body.longitude !== undefined && { longitude: body.longitude }),
|
||||
...(body.status !== undefined && { status: body.status }),
|
||||
...(body.event_category !== undefined && { eventCategory: body.event_category }),
|
||||
...(body.metadata !== undefined && { metadata: body.metadata }),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(event)
|
||||
} catch (err) {
|
||||
console.error('Event PUT error:', err)
|
||||
return NextResponse.json({ error: 'Failed to update event' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
await prisma.event.delete({ where: { id: params.id } })
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (err) {
|
||||
console.error('Event DELETE error:', err)
|
||||
return NextResponse.json({ error: 'Failed to delete event' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const start = searchParams.get('start')
|
||||
const end = searchParams.get('end')
|
||||
const source = searchParams.get('source')
|
||||
const rToolSource = searchParams.get('r_tool_source')
|
||||
const rToolEntityId = searchParams.get('r_tool_entity_id')
|
||||
const limit = parseInt(searchParams.get('limit') || '500', 10)
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10)
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
|
||||
if (start) {
|
||||
where.start = { ...(where.start as object || {}), gte: new Date(start) }
|
||||
}
|
||||
if (end) {
|
||||
where.end = { ...(where.end as object || {}), lte: new Date(end) }
|
||||
}
|
||||
if (source) {
|
||||
where.sourceId = source
|
||||
}
|
||||
if (rToolSource) {
|
||||
where.rToolSource = rToolSource
|
||||
}
|
||||
if (rToolEntityId) {
|
||||
where.rToolEntityId = rToolEntityId
|
||||
}
|
||||
|
||||
try {
|
||||
const [events, count] = await Promise.all([
|
||||
prisma.event.findMany({
|
||||
where,
|
||||
include: {
|
||||
source: {
|
||||
select: { id: true, name: true, color: true, sourceType: true },
|
||||
},
|
||||
location: {
|
||||
select: { id: true, name: true, slug: true, granularity: true, latitude: true, longitude: true },
|
||||
},
|
||||
},
|
||||
orderBy: { start: 'asc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.event.count({ where }),
|
||||
])
|
||||
|
||||
// Transform to API response format matching UnifiedEvent
|
||||
const results = events.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.sourceId,
|
||||
source_name: e.source.name,
|
||||
source_color: e.source.color,
|
||||
source_type: e.source.sourceType,
|
||||
external_id: e.externalId,
|
||||
title: e.title,
|
||||
description: e.description,
|
||||
start: e.start.toISOString(),
|
||||
end: e.end.toISOString(),
|
||||
all_day: e.allDay,
|
||||
timezone_str: e.timezoneStr,
|
||||
rrule: e.rrule,
|
||||
is_recurring: e.isRecurring,
|
||||
location: e.locationId,
|
||||
location_raw: e.locationRaw,
|
||||
location_display: e.location?.name || null,
|
||||
location_breadcrumb: null,
|
||||
latitude: e.latitude,
|
||||
longitude: e.longitude,
|
||||
coordinates: e.latitude && e.longitude ? { latitude: e.latitude, longitude: e.longitude } : null,
|
||||
location_granularity: e.location?.granularity || null,
|
||||
is_virtual: e.isVirtual,
|
||||
virtual_url: e.virtualUrl,
|
||||
virtual_platform: e.virtualPlatform,
|
||||
organizer_name: e.organizerName,
|
||||
organizer_email: e.organizerEmail,
|
||||
attendees: e.attendees,
|
||||
attendee_count: e.attendeeCount,
|
||||
status: e.status,
|
||||
visibility: e.visibility,
|
||||
duration_minutes: Math.round((e.end.getTime() - e.start.getTime()) / 60000),
|
||||
is_upcoming: e.start > new Date(),
|
||||
is_ongoing: e.start <= new Date() && e.end >= new Date(),
|
||||
r_tool_source: e.rToolSource,
|
||||
r_tool_entity_id: e.rToolEntityId,
|
||||
event_category: e.eventCategory,
|
||||
metadata: e.metadata,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ count, results })
|
||||
} catch (err) {
|
||||
console.error('Events GET error:', err)
|
||||
return NextResponse.json({ error: 'Failed to fetch events' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
sourceId: body.source_id,
|
||||
externalId: body.external_id || '',
|
||||
title: body.title,
|
||||
description: body.description || '',
|
||||
start: new Date(body.start),
|
||||
end: new Date(body.end),
|
||||
allDay: body.all_day || false,
|
||||
timezoneStr: body.timezone_str || 'UTC',
|
||||
rrule: body.rrule || '',
|
||||
isRecurring: body.is_recurring || false,
|
||||
locationRaw: body.location_raw || '',
|
||||
locationId: body.location_id || null,
|
||||
latitude: body.latitude || null,
|
||||
longitude: body.longitude || null,
|
||||
isVirtual: body.is_virtual || false,
|
||||
virtualUrl: body.virtual_url || '',
|
||||
virtualPlatform: body.virtual_platform || '',
|
||||
organizerName: body.organizer_name || '',
|
||||
organizerEmail: body.organizer_email || '',
|
||||
attendees: body.attendees || [],
|
||||
attendeeCount: body.attendee_count || 0,
|
||||
status: body.status || 'confirmed',
|
||||
visibility: body.visibility || 'default',
|
||||
rToolSource: body.r_tool_source || null,
|
||||
rToolEntityId: body.r_tool_entity_id || null,
|
||||
eventCategory: body.event_category || null,
|
||||
metadata: body.metadata || null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(event, { status: 201 })
|
||||
} catch (err) {
|
||||
console.error('Events POST error:', err)
|
||||
return NextResponse.json({ error: 'Failed to create event' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
database: 'connected',
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
database: 'disconnected',
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getLunarPhaseForDate, getLunarDataForRange, getSynodicMonth, getMoonTimesForLocation } from '@/lib/lunar'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl
|
||||
const start = searchParams.get('start')
|
||||
const end = searchParams.get('end')
|
||||
const date = searchParams.get('date')
|
||||
const lat = searchParams.get('lat')
|
||||
const lng = searchParams.get('lng')
|
||||
|
||||
try {
|
||||
// Single date query
|
||||
if (date && !start && !end) {
|
||||
const d = new Date(date)
|
||||
const phase = getLunarPhaseForDate(d)
|
||||
const synodic = getSynodicMonth(d)
|
||||
|
||||
const result: Record<string, unknown> = { date, phase, synodic_month: synodic }
|
||||
|
||||
// Add location-aware data if lat/lng provided
|
||||
if (lat && lng) {
|
||||
const moonTimes = getMoonTimesForLocation(d, parseFloat(lat), parseFloat(lng))
|
||||
result.moon_times = {
|
||||
rise: moonTimes.rise?.toISOString() || null,
|
||||
set: moonTimes.set?.toISOString() || null,
|
||||
always_up: moonTimes.alwaysUp,
|
||||
always_down: moonTimes.alwaysDown,
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
|
||||
// Date range query
|
||||
if (start && end) {
|
||||
const startDate = new Date(start)
|
||||
const endDate = new Date(end)
|
||||
|
||||
// Limit range to 366 days
|
||||
const daysDiff = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (daysDiff > 366) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Date range cannot exceed 366 days' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const lunarData = getLunarDataForRange(startDate, endDate)
|
||||
|
||||
// Convert Map to plain object for JSON serialization
|
||||
const phases: Record<string, unknown> = {}
|
||||
lunarData.forEach((value, key) => {
|
||||
phases[key] = value
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
start,
|
||||
end,
|
||||
count: lunarData.size,
|
||||
phases,
|
||||
})
|
||||
}
|
||||
|
||||
// Default: today
|
||||
const today = new Date()
|
||||
const phase = getLunarPhaseForDate(today)
|
||||
const synodic = getSynodicMonth(today)
|
||||
|
||||
return NextResponse.json({
|
||||
date: today.toISOString().split('T')[0],
|
||||
phase,
|
||||
synodic_month: synodic,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Lunar API error:', err)
|
||||
return NextResponse.json({ error: 'Failed to compute lunar data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const source = await prisma.calendarSource.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { _count: { select: { events: true } } },
|
||||
})
|
||||
|
||||
if (!source) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...source,
|
||||
event_count: source._count.events,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Source GET error:', err)
|
||||
return NextResponse.json({ error: 'Failed to fetch source' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const source = await prisma.calendarSource.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
...(body.name !== undefined && { name: body.name }),
|
||||
...(body.color !== undefined && { color: body.color }),
|
||||
...(body.is_visible !== undefined && { isVisible: body.is_visible }),
|
||||
...(body.is_active !== undefined && { isActive: body.is_active }),
|
||||
...(body.sync_config !== undefined && { syncConfig: body.sync_config }),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(source)
|
||||
} catch (err) {
|
||||
console.error('Source PUT error:', err)
|
||||
return NextResponse.json({ error: 'Failed to update source' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
await prisma.calendarSource.delete({ where: { id: params.id } })
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (err) {
|
||||
console.error('Source DELETE error:', err)
|
||||
return NextResponse.json({ error: 'Failed to delete source' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sources = await prisma.calendarSource.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
_count: { select: { events: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const results = sources.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
source_type: s.sourceType,
|
||||
source_type_display: s.sourceType.charAt(0).toUpperCase() + s.sourceType.slice(1),
|
||||
color: s.color,
|
||||
is_visible: s.isVisible,
|
||||
is_active: s.isActive,
|
||||
last_synced_at: s.lastSyncedAt?.toISOString() || null,
|
||||
sync_error: s.syncError,
|
||||
event_count: s._count.events,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ count: results.length, results })
|
||||
} catch (err) {
|
||||
console.error('Sources GET error:', err)
|
||||
return NextResponse.json({ error: 'Failed to fetch sources' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const source = await prisma.calendarSource.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
sourceType: body.source_type || 'manual',
|
||||
color: body.color || '#6b7280',
|
||||
isVisible: body.is_visible ?? true,
|
||||
isActive: body.is_active ?? true,
|
||||
syncConfig: body.sync_config || null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(source, { status: 201 })
|
||||
} catch (err) {
|
||||
console.error('Sources POST error:', err)
|
||||
return NextResponse.json({ error: 'Failed to create source' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Suspense } from 'react'
|
||||
import { RCalProvider } from '@/components/context/RCalProvider'
|
||||
import { TabLayout } from '@/components/ui/TabLayout'
|
||||
import { TemporalTab } from '@/components/tabs/TemporalTab'
|
||||
import { SpatialTab } from '@/components/tabs/SpatialTab'
|
||||
import { LunarTab } from '@/components/tabs/LunarTab'
|
||||
import { ContextTab } from '@/components/tabs/ContextTab'
|
||||
import { CalendarHeader } from '@/components/calendar/CalendarHeader'
|
||||
import type { RCalContext, RToolName, TabView, TemporalGranularity } from '@/lib/types'
|
||||
|
||||
const VALID_TOOLS: RToolName[] = ['rTrips', 'rNetwork', 'rMaps', 'rCart', 'rNotes', 'standalone']
|
||||
|
||||
function ContextPageInner() {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const tool = searchParams.get('tool') as RToolName | null
|
||||
const entityId = searchParams.get('entityId') || undefined
|
||||
const entityType = searchParams.get('entityType') || undefined
|
||||
const defaultTab = searchParams.get('tab') as TabView | undefined
|
||||
const startDate = searchParams.get('start')
|
||||
const endDate = searchParams.get('end')
|
||||
|
||||
if (!tool || !VALID_TOOLS.includes(tool)) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center space-y-4 max-w-md p-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Invalid context
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Missing or invalid <code className="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">tool</code> parameter.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
Valid tools: {VALID_TOOLS.join(', ')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Example: <code className="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">/context?tool=rTrips&entityId=trip-123</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const context: RCalContext = {
|
||||
tool,
|
||||
entityId,
|
||||
entityType,
|
||||
dateRange:
|
||||
startDate && endDate
|
||||
? { start: new Date(startDate), end: new Date(endDate) }
|
||||
: undefined,
|
||||
viewConfig: {
|
||||
defaultTab: defaultTab || 'context',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<RCalProvider context={context}>
|
||||
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<CalendarHeader
|
||||
onToggleSidebar={() => {}}
|
||||
sidebarOpen={false}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<TabLayout>
|
||||
{{
|
||||
temporal: <TemporalTab />,
|
||||
spatial: <SpatialTab />,
|
||||
lunar: <LunarTab />,
|
||||
context: <ContextTab />,
|
||||
}}
|
||||
</TabLayout>
|
||||
</div>
|
||||
</div>
|
||||
</RCalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ContextPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-gray-500">Loading context...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ContextPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
@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;
|
||||
}
|
||||
|
||||
/* Event indicators */
|
||||
.event-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 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 '@/providers'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
|
||||
const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'rCal | Relational Calendar',
|
||||
description: 'Spatiotemporal calendar with lunar overlay and multi-granularity navigation',
|
||||
}
|
||||
|
||||
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,170 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Calendar as CalendarIcon, MapPin, Clock, ZoomIn, ZoomOut, Link2, Unlink2 } from 'lucide-react'
|
||||
import { TemporalZoomController } from '@/components/calendar'
|
||||
import { CalendarHeader } from '@/components/calendar/CalendarHeader'
|
||||
import { CalendarSidebar } from '@/components/calendar/CalendarSidebar'
|
||||
import { TabLayout } from '@/components/ui/TabLayout'
|
||||
import { TemporalTab } from '@/components/tabs/TemporalTab'
|
||||
import { SpatialTab } from '@/components/tabs/SpatialTab'
|
||||
import { LunarTab } from '@/components/tabs/LunarTab'
|
||||
import { ContextTab } from '@/components/tabs/ContextTab'
|
||||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { TemporalGranularity, TEMPORAL_GRANULARITY_LABELS, GRANULARITY_LABELS } from '@/lib/types'
|
||||
import type { TabView } from '@/lib/types'
|
||||
|
||||
export default function Home() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [zoomPanelOpen, setZoomPanelOpen] = useState(false)
|
||||
const {
|
||||
temporalGranularity,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
zoomCoupled,
|
||||
toggleZoomCoupled,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
} = useCalendarStore()
|
||||
const effectiveSpatial = useEffectiveSpatialGranularity()
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'l':
|
||||
case 'L':
|
||||
e.preventDefault()
|
||||
toggleZoomCoupled()
|
||||
break
|
||||
// Tab switching: 1-4
|
||||
case '1':
|
||||
e.preventDefault()
|
||||
setActiveTab('temporal')
|
||||
break
|
||||
case '2':
|
||||
e.preventDefault()
|
||||
setActiveTab('spatial')
|
||||
break
|
||||
case '3':
|
||||
e.preventDefault()
|
||||
setActiveTab('lunar')
|
||||
break
|
||||
case '4':
|
||||
e.preventDefault()
|
||||
setActiveTab('context')
|
||||
break
|
||||
}
|
||||
},
|
||||
[toggleZoomCoupled, setActiveTab]
|
||||
)
|
||||
|
||||
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">
|
||||
{/* Tab layout */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<TabLayout>
|
||||
{{
|
||||
temporal: <TemporalTab />,
|
||||
spatial: <SpatialTab />,
|
||||
lunar: <LunarTab />,
|
||||
context: <ContextTab />,
|
||||
}}
|
||||
</TabLayout>
|
||||
</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" />
|
||||
Gregorian
|
||||
</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>
|
||||
{activeTab === 'spatial' && (
|
||||
<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={() => 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,167 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronLeft, ChevronRight, Menu, Calendar, Moon, Settings, MapPin, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import { TemporalGranularity, TEMPORAL_GRANULARITY_LABELS } from '@/lib/types'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface CalendarHeaderProps {
|
||||
onToggleSidebar: () => void
|
||||
sidebarOpen: boolean
|
||||
}
|
||||
|
||||
export function CalendarHeader({ onToggleSidebar, sidebarOpen }: CalendarHeaderProps) {
|
||||
const {
|
||||
currentDate,
|
||||
goToToday,
|
||||
navigateByGranularity,
|
||||
temporalGranularity,
|
||||
setTemporalGranularity,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
showLunarOverlay,
|
||||
setShowLunarOverlay,
|
||||
} = useCalendarStore()
|
||||
|
||||
// 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 currentDate.getFullYear().toString()
|
||||
case TemporalGranularity.MONTH:
|
||||
return 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">
|
||||
rCal
|
||||
</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 - settings */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Show lunar overlay toggle */}
|
||||
<button
|
||||
onClick={() => setShowLunarOverlay(!showLunarOverlay)}
|
||||
className={clsx(
|
||||
'p-2 rounded-lg transition-colors',
|
||||
showLunarOverlay
|
||||
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400'
|
||||
: 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
)}
|
||||
title={showLunarOverlay ? 'Hide lunar overlay' : 'Show lunar overlay'}
|
||||
>
|
||||
<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>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
'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 { SpatialGranularity, GRANULARITY_LABELS } from '@/lib/types'
|
||||
import { useSources } from '@/hooks/useEvents'
|
||||
import { syncAllSources } from '@/lib/api'
|
||||
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 [isSyncing, setIsSyncing] = useState(false)
|
||||
const [syncStatus, setSyncStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
|
||||
const { currentDate, setCurrentDate, hiddenSources, toggleSourceVisibility } = useCalendarStore()
|
||||
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>
|
||||
|
||||
{/* Lunar phase navigation - Phase 2 */}
|
||||
</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,221 @@
|
|||
'use client'
|
||||
|
||||
import { X, MapPin, Calendar, Clock, Video, Users, ExternalLink, Repeat } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getEvent } from '@/lib/api'
|
||||
import { getSemanticLocationLabel } from '@/lib/location'
|
||||
import type { UnifiedEvent } from '@/lib/types'
|
||||
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>
|
||||
|
||||
{/* Lunar phase display - Phase 2 */}
|
||||
|
||||
{/* 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,267 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { getSemanticLocationLabel } from '@/lib/location'
|
||||
import { useMonthEvents, groupEventsByDate } from '@/hooks/useEvents'
|
||||
import type { EventListItem } from '@/lib/types'
|
||||
import { EventDetailModal } from './EventDetailModal'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface DayCell {
|
||||
date: Date
|
||||
dateKey: string
|
||||
day: number
|
||||
isCurrentMonth: boolean
|
||||
isToday: boolean
|
||||
}
|
||||
|
||||
export function MonthView() {
|
||||
const { currentDate, showLunarOverlay, 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(() => {
|
||||
return generateGregorianMonth(currentDate)
|
||||
}, [currentDate])
|
||||
|
||||
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
{/* 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">
|
||||
{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>
|
||||
</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'
|
||||
)}
|
||||
>
|
||||
{day.slice(0, 3)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7 gap-px bg-gray-100 dark:bg-gray-700 calendar-grid">
|
||||
{monthData.days.map((day, i) => (
|
||||
<DayCellComponent
|
||||
key={i}
|
||||
day={day}
|
||||
showLunarOverlay={showLunarOverlay}
|
||||
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,
|
||||
showLunarOverlay,
|
||||
events,
|
||||
isLoading,
|
||||
onEventClick,
|
||||
effectiveSpatial,
|
||||
}: {
|
||||
day: DayCell
|
||||
showLunarOverlay: 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>
|
||||
|
||||
{/* Lunar phase overlay slot - Phase 2 */}
|
||||
</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)
|
||||
days.push({
|
||||
date: d,
|
||||
dateKey: d.toISOString().split('T')[0],
|
||||
day: dayNum,
|
||||
isCurrentMonth: false,
|
||||
isToday: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Current month's days
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
const d = new Date(year, month - 1, i)
|
||||
days.push({
|
||||
date: d,
|
||||
dateKey: d.toISOString().split('T')[0],
|
||||
day: i,
|
||||
isCurrentMonth: true,
|
||||
isToday: d.getTime() === today.getTime(),
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
days.push({
|
||||
date: d,
|
||||
dateKey: d.toISOString().split('T')[0],
|
||||
day: i,
|
||||
isCurrentMonth: false,
|
||||
isToday: false,
|
||||
})
|
||||
}
|
||||
|
||||
return { year, month, days }
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import { TemporalGranularity } from '@/lib/types'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface MonthGridProps {
|
||||
year: number
|
||||
month: number // 1-indexed
|
||||
onDayClick?: (date: Date) => void
|
||||
}
|
||||
|
||||
function MonthGrid({ year, month, onDayClick }: MonthGridProps) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const monthData = useMemo(() => {
|
||||
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, today])
|
||||
|
||||
const monthName = new Date(year, month - 1).toLocaleDateString('en-US', { month: 'long' })
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Month header */}
|
||||
<div
|
||||
className="text-center py-2 rounded-t-lg font-semibold"
|
||||
style={{
|
||||
backgroundColor: 'rgb(243 244 246)',
|
||||
color: '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,
|
||||
setCurrentDate,
|
||||
setTemporalGranularity,
|
||||
} = useCalendarStore()
|
||||
|
||||
const year = currentDate.getFullYear()
|
||||
|
||||
// Determine current quarter
|
||||
const currentMonth = currentDate.getMonth() + 1
|
||||
const currentQuarter = Math.ceil(currentMonth / 3)
|
||||
|
||||
// Get the months for this quarter
|
||||
const quarterMonths = useMemo(() => {
|
||||
const startMonth = (currentQuarter - 1) * 3 + 1
|
||||
return [startMonth, startMonth + 1, startMonth + 2]
|
||||
}, [currentQuarter])
|
||||
|
||||
const seasonName = ['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} • 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}
|
||||
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 arrow keys to navigate quarters
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
'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 } from '@/lib/location'
|
||||
import { SpatialGranularity, SPATIAL_TO_LEAFLET_ZOOM, GRANULARITY_LABELS, leafletZoomToSpatial } from '@/lib/types'
|
||||
import type { EventListItem, UnifiedEvent } from '@/lib/types'
|
||||
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 '@/lib/types'
|
||||
|
||||
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 '@/lib/types'
|
||||
|
||||
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,409 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { TemporalGranularity, TEMPORAL_GRANULARITY_LABELS, SpatialGranularity, GRANULARITY_LABELS } from '@/lib/types'
|
||||
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,534 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import { TemporalGranularity } from '@/lib/types'
|
||||
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
|
||||
isCurrentMonth: boolean
|
||||
onMonthClick: (month: number) => void
|
||||
eventCounts?: Record<number, number>
|
||||
}
|
||||
|
||||
function MiniMonth({
|
||||
year,
|
||||
month,
|
||||
isCurrentMonth,
|
||||
onMonthClick,
|
||||
eventCounts = {},
|
||||
}: MiniMonthProps) {
|
||||
const monthData = useMemo(() => {
|
||||
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])
|
||||
|
||||
const monthName = new Date(year, month - 1).toLocaleDateString('en-US', { month: 'short' })
|
||||
const monthColor = 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
|
||||
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, onDayClick }: GlanceMonthColumnProps) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
// Build a grid aligned by weekday
|
||||
const calendarGrid = useMemo(() => {
|
||||
const slots: CalendarSlot[] = []
|
||||
|
||||
// Gregorian: Variable days, variable start day
|
||||
const firstDay = new Date(year, month - 1, 1)
|
||||
const lastDay = new Date(year, month, 0)
|
||||
const daysInMonth = lastDay.getDate()
|
||||
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, today])
|
||||
|
||||
const monthName = new Date(year, month - 1).toLocaleDateString('en-US', { month: 'short' })
|
||||
const monthColor = 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>
|
||||
)
|
||||
}
|
||||
|
||||
// Fullscreen Glance View Portal
|
||||
interface FullscreenGlanceProps {
|
||||
year: number
|
||||
onDayClick: (date: Date) => void
|
||||
onClose: () => void
|
||||
navigatePrev: () => void
|
||||
navigateNext: () => void
|
||||
}
|
||||
|
||||
function FullscreenGlance({
|
||||
year,
|
||||
onDayClick,
|
||||
onClose,
|
||||
navigatePrev,
|
||||
navigateNext,
|
||||
}: FullscreenGlanceProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
|
||||
// Handle escape key
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
navigatePrev()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
navigateNext()
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent body scroll
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose, navigatePrev, navigateNext])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
const content = (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-gradient-to-br from-slate-100 via-blue-50 to-purple-50 dark:from-gray-900 dark:via-slate-900 dark:to-gray-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-3 flex-shrink-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={navigatePrev}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title="Previous year"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
{year}
|
||||
</h1>
|
||||
<button
|
||||
onClick={navigateNext}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title="Next year"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 bg-white/50 dark:bg-gray-800/50 px-3 py-1 rounded-full">
|
||||
Gregorian Calendar
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-sm"
|
||||
title="Exit fullscreen (Esc)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Exit Fullscreen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Month columns - takes up remaining space */}
|
||||
<div className="flex-1 flex gap-1 p-3 overflow-x-auto min-h-0">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<GlanceMonthColumn
|
||||
key={i + 1}
|
||||
year={year}
|
||||
month={i + 1}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-xs text-gray-500 dark:text-gray-400 py-2 bg-white/50 dark:bg-gray-900/50 backdrop-blur flex-shrink-0">
|
||||
Click any day to zoom in • Arrow keys navigate years • Esc to exit • T for today
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
|
||||
export function YearView() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('glance')
|
||||
const {
|
||||
currentDate,
|
||||
setCurrentDate,
|
||||
setViewType,
|
||||
setTemporalGranularity,
|
||||
navigateByGranularity,
|
||||
} = useCalendarStore()
|
||||
|
||||
const year = currentDate.getFullYear()
|
||||
const currentMonth = currentDate.getMonth() + 1
|
||||
|
||||
const handleMonthClick = (month: number) => {
|
||||
setCurrentDate(new Date(year, month - 1, 1))
|
||||
setTemporalGranularity(TemporalGranularity.MONTH)
|
||||
setViewType('month')
|
||||
}
|
||||
|
||||
const handleDayClick = (date: Date) => {
|
||||
setCurrentDate(date)
|
||||
setTemporalGranularity(TemporalGranularity.DAY)
|
||||
}
|
||||
|
||||
const months = 12
|
||||
|
||||
// Mock event counts for compact view
|
||||
const mockEventCounts = useMemo(() => {
|
||||
const counts: Record<number, Record<number, number>> = {}
|
||||
for (let m = 1; m <= months; m++) {
|
||||
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}
|
||||
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">
|
||||
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="grid gap-3 grid-cols-4 md:grid-cols-6">
|
||||
{Array.from({ length: months }).map((_, i) => {
|
||||
const month = i + 1
|
||||
const isCurrentMonthView = currentMonth === month && currentDate.getFullYear() === year
|
||||
|
||||
return (
|
||||
<MiniMonth
|
||||
key={`month-${month}`}
|
||||
year={year}
|
||||
month={month}
|
||||
isCurrentMonth={isCurrentMonthView}
|
||||
onMonthClick={handleMonthClick}
|
||||
eventCounts={mockEventCounts[month]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</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,40 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import type { RCalContext } from '@/lib/types'
|
||||
|
||||
interface RCalProviderProps {
|
||||
context: RCalContext
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component that sets the r* tool context in the store.
|
||||
* On mount, sets the context and optionally switches to the context tab.
|
||||
* On unmount, clears the context.
|
||||
*/
|
||||
export function RCalProvider({ context, children }: RCalProviderProps) {
|
||||
const { setRCalContext, setActiveTab, setTemporalGranularity } = useCalendarStore()
|
||||
|
||||
useEffect(() => {
|
||||
setRCalContext(context)
|
||||
|
||||
// Apply view config overrides from the calling tool
|
||||
if (context.viewConfig?.defaultTab) {
|
||||
setActiveTab(context.viewConfig.defaultTab)
|
||||
} else {
|
||||
setActiveTab('context')
|
||||
}
|
||||
|
||||
if (context.viewConfig?.defaultGranularity !== undefined) {
|
||||
setTemporalGranularity(context.viewConfig.defaultGranularity)
|
||||
}
|
||||
|
||||
return () => {
|
||||
setRCalContext(null)
|
||||
}
|
||||
}, [context, setRCalContext, setActiveTab, setTemporalGranularity])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { getLunarDataForRange } from '@/lib/lunar'
|
||||
import type { LunarPhaseInfo } from '@/lib/types'
|
||||
|
||||
/**
|
||||
* Hook to compute lunar data for a given month.
|
||||
* Returns a Map<string, LunarPhaseInfo> keyed by "YYYY-MM-DD".
|
||||
*/
|
||||
export function useLunarMonth(year: number, month: number): Map<string, LunarPhaseInfo> {
|
||||
return useMemo(() => {
|
||||
const start = new Date(year, month - 1, 1)
|
||||
const end = new Date(year, month, 0) // Last day of month
|
||||
return getLunarDataForRange(start, end)
|
||||
}, [year, month])
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
'use client'
|
||||
|
||||
import type { LunarPhaseInfo } from '@/lib/types'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface MoonPhaseIconProps {
|
||||
phase: LunarPhaseInfo
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-lg',
|
||||
lg: 'text-2xl',
|
||||
}
|
||||
|
||||
export function MoonPhaseIcon({ phase, size = 'sm', showLabel = false }: MoonPhaseIconProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-0.5',
|
||||
SIZE_CLASSES[size]
|
||||
)}
|
||||
title={`${phase.phase.replace(/_/g, ' ')} (${(phase.illumination * 100).toFixed(0)}%)`}
|
||||
>
|
||||
<span>{phase.emoji}</span>
|
||||
{showLabel && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||
{phase.phase.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Layers, Loader2, Calendar } from 'lucide-react'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import { getAdapter, getAllAdapters } from '@/lib/context-adapters'
|
||||
import type { RCalEvent } from '@/lib/types'
|
||||
|
||||
export function ContextTab() {
|
||||
const { rCalContext } = useCalendarStore()
|
||||
const [events, setEvents] = useState<RCalEvent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!rCalContext) {
|
||||
setEvents([])
|
||||
return
|
||||
}
|
||||
|
||||
const adapter = getAdapter(rCalContext.tool)
|
||||
if (!adapter) {
|
||||
setError(`No adapter registered for ${rCalContext.tool}`)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
adapter
|
||||
.fetchEvents(rCalContext)
|
||||
.then(setEvents)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [rCalContext])
|
||||
|
||||
if (!rCalContext) {
|
||||
const adapters = getAllAdapters()
|
||||
return (
|
||||
<main className="h-full flex items-center justify-center p-8">
|
||||
<div className="text-center space-y-6 max-w-lg">
|
||||
<Layers className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto" />
|
||||
<h2 className="text-xl font-semibold text-gray-700 dark:text-gray-300">
|
||||
No context active
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Open rcal from another r* tool to view context-specific calendar data.
|
||||
The Context tab adapts its rendering based on which tool is driving it.
|
||||
</p>
|
||||
|
||||
{/* Available adapters */}
|
||||
<div className="space-y-2 pt-2">
|
||||
<p className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
Registered adapters
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
{adapters.map((a) => (
|
||||
<div
|
||||
key={a.toolName}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-800/50 text-left"
|
||||
>
|
||||
<Calendar className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{a.toolName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{a.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Or visit{' '}
|
||||
<code className="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">
|
||||
/context?tool=rTrips&entityId=...
|
||||
</code>{' '}
|
||||
to load a context directly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const adapter = getAdapter(rCalContext.tool)
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-auto p-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Context header */}
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<Layers className="w-6 h-6 text-blue-500" />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{adapter?.label || rCalContext.tool}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{adapter?.description}
|
||||
{rCalContext.entityId && (
|
||||
<span className="ml-2 text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
|
||||
{rCalContext.entityType || 'entity'}: {rCalContext.entityId}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 gap-2 text-gray-500">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>Loading {rCalContext.tool} events...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events list */}
|
||||
{!loading && !error && events.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
No events found for this context.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && events.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{events.length} event{events.length !== 1 ? 's' : ''} from {rCalContext.tool}
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: event.source_color || '#6b7280' }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{event.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(event.start).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{event.eventCategory && (
|
||||
<span className="ml-2 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">
|
||||
{event.eventCategory}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Moon } from 'lucide-react'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import { getLunarPhaseForDate, getSynodicMonth } from '@/lib/lunar'
|
||||
import type { LunarPhaseName } from '@/lib/types'
|
||||
|
||||
const PHASE_COLORS: Record<LunarPhaseName, string> = {
|
||||
new_moon: 'bg-lunar-new',
|
||||
waxing_crescent: 'bg-lunar-waxing',
|
||||
first_quarter: 'bg-lunar-first',
|
||||
waxing_gibbous: 'bg-lunar-gibbous',
|
||||
full_moon: 'bg-lunar-full',
|
||||
waning_gibbous: 'bg-lunar-waning',
|
||||
last_quarter: 'bg-lunar-last',
|
||||
waning_crescent: 'bg-lunar-crescent',
|
||||
}
|
||||
|
||||
export function LunarTab() {
|
||||
const { currentDate } = useCalendarStore()
|
||||
|
||||
const todayPhase = useMemo(() => getLunarPhaseForDate(currentDate), [currentDate])
|
||||
const synodicMonth = useMemo(() => getSynodicMonth(currentDate), [currentDate])
|
||||
|
||||
// Progress through current synodic month (0-1)
|
||||
const progress = useMemo(() => {
|
||||
const elapsed = currentDate.getTime() - synodicMonth.startDate.getTime()
|
||||
const total = synodicMonth.endDate.getTime() - synodicMonth.startDate.getTime()
|
||||
return Math.max(0, Math.min(1, elapsed / total))
|
||||
}, [currentDate, synodicMonth])
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-auto p-6">
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
{/* Current phase hero */}
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-8xl">{todayPhase.emoji}</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white capitalize">
|
||||
{todayPhase.phase.replace(/_/g, ' ')}
|
||||
</h2>
|
||||
<div className="flex justify-center gap-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Illumination: {(todayPhase.illumination * 100).toFixed(1)}%</span>
|
||||
<span>Age: {todayPhase.age.toFixed(1)} days</span>
|
||||
<span>Cycle: {synodicMonth.durationDays.toFixed(1)} days</span>
|
||||
</div>
|
||||
{todayPhase.isEclipse && (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-full text-sm font-medium">
|
||||
Eclipse{todayPhase.eclipseType ? ` (${todayPhase.eclipseType})` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Synodic month progress bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>New Moon: {synodicMonth.startDate.toLocaleDateString()}</span>
|
||||
<span>Next New Moon: {synodicMonth.endDate.toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden relative">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-lunar-new via-lunar-full to-lunar-new rounded-full transition-all"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
{/* Phase markers */}
|
||||
{synodicMonth.phases.map((p, i) => {
|
||||
const phaseProgress =
|
||||
(p.date.getTime() - synodicMonth.startDate.getTime()) /
|
||||
(synodicMonth.endDate.getTime() - synodicMonth.startDate.getTime())
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-0 h-full flex items-center"
|
||||
style={{ left: `${phaseProgress * 100}%` }}
|
||||
title={`${p.emoji} ${p.phase.replace(/_/g, ' ')}\n${p.date.toLocaleDateString()}`}
|
||||
>
|
||||
<span className="text-xs -translate-x-1/2">{p.emoji}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase timeline */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Moon className="w-5 h-5" />
|
||||
Phases this cycle
|
||||
</h3>
|
||||
<div className="grid gap-2">
|
||||
{synodicMonth.phases.map((phase, i) => {
|
||||
const isPast = phase.date < currentDate
|
||||
const isCurrent = phase.phase === todayPhase.phase
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
|
||||
isCurrent
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: isPast
|
||||
? 'border-gray-200 dark:border-gray-700 opacity-60'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{phase.emoji}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-white capitalize">
|
||||
{phase.phase.replace(/_/g, ' ')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{phase.date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${PHASE_COLORS[phase.phase]}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import { useEvents } from '@/hooks/useEvents'
|
||||
import { TemporalGranularity } from '@/lib/types'
|
||||
import { SplitView } from '@/components/calendar/SplitView'
|
||||
import { MonthView } from '@/components/calendar/MonthView'
|
||||
import { SeasonView } from '@/components/calendar/SeasonView'
|
||||
import { YearView } from '@/components/calendar/YearView'
|
||||
|
||||
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],
|
||||
}
|
||||
}
|
||||
|
||||
function CalendarView() {
|
||||
const { temporalGranularity } = useCalendarStore()
|
||||
|
||||
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 />
|
||||
}
|
||||
}
|
||||
|
||||
export function SpatialTab() {
|
||||
const { temporalGranularity, currentDate, hiddenSources } = useCalendarStore()
|
||||
|
||||
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])
|
||||
|
||||
return (
|
||||
<SplitView
|
||||
calendarContent={<CalendarView />}
|
||||
events={visibleEvents}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import { TemporalGranularity } from '@/lib/types'
|
||||
import { MonthView } from '@/components/calendar/MonthView'
|
||||
import { SeasonView } from '@/components/calendar/SeasonView'
|
||||
import { YearView } from '@/components/calendar/YearView'
|
||||
|
||||
export function TemporalTab() {
|
||||
const { temporalGranularity } = useCalendarStore()
|
||||
|
||||
const CalendarView = () => {
|
||||
switch (temporalGranularity) {
|
||||
case TemporalGranularity.YEAR:
|
||||
case TemporalGranularity.DECADE:
|
||||
return <YearView />
|
||||
case TemporalGranularity.SEASON:
|
||||
return <SeasonView />
|
||||
case TemporalGranularity.MONTH:
|
||||
case TemporalGranularity.WEEK:
|
||||
case TemporalGranularity.DAY:
|
||||
default:
|
||||
return <MonthView />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-auto p-4">
|
||||
<CalendarView />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
'use client'
|
||||
|
||||
import { Calendar, Map, Moon, Layers } from 'lucide-react'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import type { TabView } from '@/lib/types'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
const TABS: Array<{
|
||||
id: TabView
|
||||
label: string
|
||||
icon: typeof Calendar
|
||||
shortcut: string
|
||||
}> = [
|
||||
{ id: 'temporal', label: 'Temporal', icon: Calendar, shortcut: '1' },
|
||||
{ id: 'spatial', label: 'Spatial', icon: Map, shortcut: '2' },
|
||||
{ id: 'lunar', label: 'Lunar', icon: Moon, shortcut: '3' },
|
||||
{ id: 'context', label: 'Context', icon: Layers, shortcut: '4' },
|
||||
]
|
||||
|
||||
interface TabLayoutProps {
|
||||
children: Record<TabView, React.ReactNode>
|
||||
}
|
||||
|
||||
export function TabLayout({ children }: TabLayoutProps) {
|
||||
const { activeTab, setActiveTab, rCalContext } = useCalendarStore()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-2">
|
||||
{TABS.map((tab) => {
|
||||
const isDisabled = tab.id === 'context' && !rCalContext
|
||||
const isActive = activeTab === tab.id
|
||||
const Icon = tab.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => !isDisabled && setActiveTab(tab.id)}
|
||||
disabled={isDisabled}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors',
|
||||
isActive
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400',
|
||||
!isActive && !isDisabled && 'hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300',
|
||||
isDisabled && 'opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
title={
|
||||
isDisabled
|
||||
? 'Open rcal from an r* tool to use Context view'
|
||||
: `${tab.label} view (${tab.shortcut})`
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{tab.id === 'context' && rCalContext ? rCalContext.tool : tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{children[activeTab]}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getEvents, getSources } from '../lib/api'
|
||||
import type { EventsResponse, SourcesResponse } from '../lib/api'
|
||||
|
||||
export type { EventsResponse, SourcesResponse }
|
||||
|
||||
export function useEvents(params?: {
|
||||
start?: string
|
||||
end?: string
|
||||
source?: string
|
||||
}) {
|
||||
return useQuery<EventsResponse>({
|
||||
queryKey: ['events', params],
|
||||
queryFn: () => getEvents(params),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMonthEvents(year: number, month: number) {
|
||||
const start = new Date(year, month - 1, 1).toISOString().split('T')[0]
|
||||
const end = new Date(year, month, 0).toISOString().split('T')[0]
|
||||
|
||||
return useQuery<EventsResponse>({
|
||||
queryKey: ['events', 'month', year, month],
|
||||
queryFn: () => getEvents({ start, end }),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSources() {
|
||||
return useQuery<SourcesResponse>({
|
||||
queryKey: ['sources'],
|
||||
queryFn: () => getSources(),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function groupEventsByDate<T extends { start: string }>(events: T[]): Map<string, T[]> {
|
||||
const grouped = new Map<string, T[]>()
|
||||
|
||||
for (const event of events) {
|
||||
const dateKey = event.start.split('T')[0]
|
||||
const existing = grouped.get(dateKey) || []
|
||||
existing.push(event)
|
||||
grouped.set(dateKey, existing)
|
||||
}
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { SPATIAL_TO_LEAFLET_ZOOM } from '@/lib/types'
|
||||
import type { MapState, EventListItem } from '@/lib/types'
|
||||
|
||||
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,163 @@
|
|||
import type { EventListItem, CalendarSource, UnifiedEvent, LunarPhaseInfo } from './types'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
async function fetcher<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${url}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ── Response Types ──
|
||||
|
||||
export interface EventsResponse {
|
||||
count: number
|
||||
next: string | null
|
||||
previous: string | null
|
||||
results: EventListItem[]
|
||||
}
|
||||
|
||||
export interface SourcesResponse {
|
||||
count: number
|
||||
next: string | null
|
||||
previous: string | null
|
||||
results: CalendarSource[]
|
||||
}
|
||||
|
||||
// ── Events API ──
|
||||
|
||||
export async function getEvents(params?: {
|
||||
start?: string
|
||||
end?: string
|
||||
source?: string
|
||||
location?: string
|
||||
search?: string
|
||||
rTool?: string
|
||||
rEntityId?: string
|
||||
}): Promise<EventsResponse> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
searchParams.append(key, String(value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const query = searchParams.toString()
|
||||
return fetcher(`/events${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export async function getEvent(id: string): Promise<UnifiedEvent> {
|
||||
return fetcher(`/events/${id}`)
|
||||
}
|
||||
|
||||
export async function getUpcomingEvents(days = 7): Promise<EventsResponse> {
|
||||
return fetcher(`/events?upcoming=${days}`)
|
||||
}
|
||||
|
||||
export async function getTodayEvents(): Promise<EventsResponse> {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return fetcher(`/events?start=${today}&end=${today}`)
|
||||
}
|
||||
|
||||
// ── Sources API ──
|
||||
|
||||
export async function getSources(params?: {
|
||||
is_active?: boolean
|
||||
is_visible?: boolean
|
||||
source_type?: string
|
||||
}): Promise<SourcesResponse> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
searchParams.append(key, String(value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const query = searchParams.toString()
|
||||
return fetcher(`/sources${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export async function syncSource(id: string) {
|
||||
return fetcher(`/sources/${id}/sync`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function syncAllSources() {
|
||||
const response = await getSources({ is_active: true })
|
||||
const syncPromises = response.results.map((source) => syncSource(source.id))
|
||||
return Promise.allSettled(syncPromises)
|
||||
}
|
||||
|
||||
// ── Locations API ──
|
||||
|
||||
export async function getLocations(params?: {
|
||||
granularity?: number
|
||||
parent?: string
|
||||
search?: string
|
||||
}) {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
searchParams.append(key, String(value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const query = searchParams.toString()
|
||||
return fetcher(`/locations${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export async function getLocationRoots() {
|
||||
return fetcher(`/locations?root=true`)
|
||||
}
|
||||
|
||||
export async function getLocationTree() {
|
||||
return fetcher(`/locations/tree`)
|
||||
}
|
||||
|
||||
// ── Lunar API ──
|
||||
|
||||
export async function getLunarData(params: {
|
||||
start: string
|
||||
end: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
}): Promise<Map<string, LunarPhaseInfo>> {
|
||||
const searchParams = new URLSearchParams({
|
||||
start: params.start,
|
||||
end: params.end,
|
||||
})
|
||||
if (params.lat !== undefined) searchParams.append('lat', String(params.lat))
|
||||
if (params.lng !== undefined) searchParams.append('lng', String(params.lng))
|
||||
|
||||
return fetcher(`/lunar?${searchParams}`)
|
||||
}
|
||||
|
||||
// ── Context API (r* tool bridge) ──
|
||||
|
||||
export async function getContextEvents(
|
||||
tool: string,
|
||||
entityId: string
|
||||
) {
|
||||
return fetcher(`/context/${tool}?entityId=${entityId}`)
|
||||
}
|
||||
|
||||
// ── Stats API ──
|
||||
|
||||
export async function getCalendarStats() {
|
||||
return fetcher(`/stats`)
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import type { RToolName, RCalContext, RCalEvent } from './types'
|
||||
|
||||
/**
|
||||
* View adapter interface for r* tool integration.
|
||||
* Each r* tool registers an adapter that knows how to fetch and render
|
||||
* context-specific calendar data.
|
||||
*/
|
||||
export interface RCalViewAdapter {
|
||||
toolName: RToolName
|
||||
label: string
|
||||
description: string
|
||||
fetchEvents(context: RCalContext): Promise<RCalEvent[]>
|
||||
}
|
||||
|
||||
// ── Adapter Registry ──
|
||||
|
||||
const adapters = new Map<RToolName, RCalViewAdapter>()
|
||||
|
||||
export function registerAdapter(adapter: RCalViewAdapter): void {
|
||||
adapters.set(adapter.toolName, adapter)
|
||||
}
|
||||
|
||||
export function getAdapter(tool: RToolName): RCalViewAdapter | undefined {
|
||||
return adapters.get(tool)
|
||||
}
|
||||
|
||||
export function getAllAdapters(): RCalViewAdapter[] {
|
||||
return Array.from(adapters.values())
|
||||
}
|
||||
|
||||
// ── Built-in Adapters ──
|
||||
|
||||
registerAdapter({
|
||||
toolName: 'rTrips',
|
||||
label: 'Trip Timeline',
|
||||
description: 'Departure → waypoints → return timeline',
|
||||
async fetchEvents(context) {
|
||||
// TODO: Connect to rTrips API when available
|
||||
// For now, return mock data showing the adapter pattern works
|
||||
if (!context.entityId) return []
|
||||
return [
|
||||
{
|
||||
id: `trip-${context.entityId}-depart`,
|
||||
source: 'rTrips',
|
||||
source_name: 'rTrips',
|
||||
source_color: '#10b981',
|
||||
source_type: 'manual',
|
||||
external_id: '',
|
||||
title: `Trip departure`,
|
||||
description: `Departure for trip ${context.entityId}`,
|
||||
start: context.dateRange?.start.toISOString() || new Date().toISOString(),
|
||||
end: context.dateRange?.start.toISOString() || new Date().toISOString(),
|
||||
all_day: true,
|
||||
timezone_str: 'UTC',
|
||||
rrule: '',
|
||||
is_recurring: false,
|
||||
location: null,
|
||||
location_raw: '',
|
||||
location_display: null,
|
||||
location_breadcrumb: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
coordinates: null,
|
||||
location_granularity: null,
|
||||
is_virtual: false,
|
||||
virtual_url: '',
|
||||
virtual_platform: '',
|
||||
organizer_name: '',
|
||||
organizer_email: '',
|
||||
attendees: [],
|
||||
attendee_count: 0,
|
||||
status: 'confirmed',
|
||||
visibility: 'default',
|
||||
duration_minutes: 0,
|
||||
is_upcoming: true,
|
||||
is_ongoing: false,
|
||||
rToolSource: 'rTrips',
|
||||
rToolEntityId: context.entityId,
|
||||
eventCategory: 'travel',
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
registerAdapter({
|
||||
toolName: 'rNetwork',
|
||||
label: 'Network Timeline',
|
||||
description: 'Relationship events and interaction history',
|
||||
async fetchEvents() {
|
||||
// Stub: will connect to rNetwork API
|
||||
return []
|
||||
},
|
||||
})
|
||||
|
||||
registerAdapter({
|
||||
toolName: 'rMaps',
|
||||
label: 'Location Events',
|
||||
description: 'Place-centric event timeline',
|
||||
async fetchEvents() {
|
||||
// Stub: will connect to rMaps API
|
||||
return []
|
||||
},
|
||||
})
|
||||
|
||||
registerAdapter({
|
||||
toolName: 'rCart',
|
||||
label: 'Transaction Timeline',
|
||||
description: 'Financial events and transaction history',
|
||||
async fetchEvents() {
|
||||
// Stub: will connect to rCart API
|
||||
return []
|
||||
},
|
||||
})
|
||||
|
||||
registerAdapter({
|
||||
toolName: 'rNotes',
|
||||
label: 'Notes Timeline',
|
||||
description: 'Notes and journal entries on a timeline',
|
||||
async fetchEvents() {
|
||||
// Stub: will connect to rNotes API
|
||||
return []
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
'use client'
|
||||
|
||||
import { Providers } from '@/providers'
|
||||
import { RCalProvider } from '@/components/context/RCalProvider'
|
||||
import { TabLayout } from '@/components/ui/TabLayout'
|
||||
import { TemporalTab } from '@/components/tabs/TemporalTab'
|
||||
import { SpatialTab } from '@/components/tabs/SpatialTab'
|
||||
import { LunarTab } from '@/components/tabs/LunarTab'
|
||||
import { ContextTab } from '@/components/tabs/ContextTab'
|
||||
import type { RCalContext } from './types'
|
||||
|
||||
interface RCalEmbedProps {
|
||||
context: RCalContext
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeddable rCal component for other r* tools to import directly.
|
||||
* Wraps the full tab layout with providers and context.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* import { RCalEmbed } from 'rcal-online/embed'
|
||||
*
|
||||
* <RCalEmbed context={{ tool: 'rTrips', entityId: 'trip-123' }} />
|
||||
* ```
|
||||
*/
|
||||
export function RCalEmbed({ context, className }: RCalEmbedProps) {
|
||||
return (
|
||||
<Providers>
|
||||
<RCalProvider context={context}>
|
||||
<div className={className || 'h-full'}>
|
||||
<TabLayout>
|
||||
{{
|
||||
temporal: <TemporalTab />,
|
||||
spatial: <SpatialTab />,
|
||||
lunar: <LunarTab />,
|
||||
context: <ContextTab />,
|
||||
}}
|
||||
</TabLayout>
|
||||
</div>
|
||||
</RCalProvider>
|
||||
</Providers>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { SpatialGranularity, UnifiedEvent, EventListItem } from './types'
|
||||
|
||||
/**
|
||||
* Returns the best location string for an event given the current spatial zoom level.
|
||||
*/
|
||||
export function getSemanticLocationLabel(
|
||||
event: UnifiedEvent | EventListItem,
|
||||
spatialGranularity: SpatialGranularity
|
||||
): string {
|
||||
if (!('location_display' in event)) {
|
||||
return (event as EventListItem).location_raw || ''
|
||||
}
|
||||
|
||||
const e = event as UnifiedEvent
|
||||
|
||||
if (!e.location_raw && !e.location_display) {
|
||||
return e.is_virtual ? (e.virtual_platform || 'Virtual') : ''
|
||||
}
|
||||
|
||||
const crumbs = e.location_breadcrumb?.split(' > ') || []
|
||||
|
||||
if (crumbs.length === 0) {
|
||||
return e.location_display || e.location_raw || ''
|
||||
}
|
||||
|
||||
switch (spatialGranularity) {
|
||||
case SpatialGranularity.PLANET:
|
||||
return crumbs[0] || 'Earth'
|
||||
case SpatialGranularity.CONTINENT:
|
||||
return crumbs[1] || crumbs[0] || e.location_display || ''
|
||||
case SpatialGranularity.BIOREGION:
|
||||
case SpatialGranularity.COUNTRY:
|
||||
return crumbs[2] || crumbs[1] || e.location_display || ''
|
||||
case SpatialGranularity.REGION:
|
||||
return crumbs[3] || crumbs[2] || e.location_display || ''
|
||||
case SpatialGranularity.CITY:
|
||||
return crumbs[3] || crumbs[2] || e.location_display || e.location_raw
|
||||
case SpatialGranularity.NEIGHBORHOOD:
|
||||
return crumbs.slice(-2).join(', ') || e.location_display || e.location_raw
|
||||
case SpatialGranularity.ADDRESS:
|
||||
case SpatialGranularity.COORDINATES:
|
||||
return e.location_raw || e.location_display || ''
|
||||
default:
|
||||
return e.location_display || e.location_raw || ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups events by their semantic location at the given granularity.
|
||||
*/
|
||||
export function groupEventsByLocation(
|
||||
events: UnifiedEvent[],
|
||||
spatialGranularity: SpatialGranularity
|
||||
): Map<string, UnifiedEvent[]> {
|
||||
const grouped = new Map<string, UnifiedEvent[]>()
|
||||
|
||||
for (const event of events) {
|
||||
const label = getSemanticLocationLabel(event, spatialGranularity)
|
||||
if (!label) continue
|
||||
const existing = grouped.get(label) || []
|
||||
existing.push(event)
|
||||
grouped.set(label, existing)
|
||||
}
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import { Moon } from 'lunarphase-js'
|
||||
import SunCalc from 'suncalc'
|
||||
import type { LunarPhaseInfo, LunarPhaseName, SynodicMonth } from './types'
|
||||
|
||||
// ── Phase mapping ──
|
||||
|
||||
const PHASE_EMOJIS: Record<LunarPhaseName, string> = {
|
||||
new_moon: '\u{1F311}',
|
||||
waxing_crescent: '\u{1F312}',
|
||||
first_quarter: '\u{1F313}',
|
||||
waxing_gibbous: '\u{1F314}',
|
||||
full_moon: '\u{1F315}',
|
||||
waning_gibbous: '\u{1F316}',
|
||||
last_quarter: '\u{1F317}',
|
||||
waning_crescent: '\u{1F318}',
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps lunarphase-js phase name to our LunarPhaseName type.
|
||||
*/
|
||||
function normalizePhaseName(phaseName: string): LunarPhaseName {
|
||||
const normalized = phaseName
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/-/g, '_')
|
||||
|
||||
const mapping: Record<string, LunarPhaseName> = {
|
||||
new: 'new_moon',
|
||||
new_moon: 'new_moon',
|
||||
waxing_crescent: 'waxing_crescent',
|
||||
first_quarter: 'first_quarter',
|
||||
waxing_gibbous: 'waxing_gibbous',
|
||||
full: 'full_moon',
|
||||
full_moon: 'full_moon',
|
||||
waning_gibbous: 'waning_gibbous',
|
||||
third_quarter: 'last_quarter',
|
||||
last_quarter: 'last_quarter',
|
||||
waning_crescent: 'waning_crescent',
|
||||
}
|
||||
|
||||
return mapping[normalized] || 'new_moon'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lunar phase info for a specific date.
|
||||
* Uses lunarphase-js for phase name/age and suncalc for illumination fraction.
|
||||
*/
|
||||
export function getLunarPhaseForDate(date: Date): LunarPhaseInfo {
|
||||
const phaseName = Moon.lunarPhase(date)
|
||||
const age = Moon.lunarAge(date)
|
||||
const phase = normalizePhaseName(phaseName)
|
||||
|
||||
// suncalc gives precise illumination fraction (0-1)
|
||||
const sunCalcIllum = SunCalc.getMoonIllumination(date)
|
||||
|
||||
return {
|
||||
phase,
|
||||
emoji: PHASE_EMOJIS[phase],
|
||||
illumination: sunCalcIllum.fraction,
|
||||
age,
|
||||
isEclipse: false, // Basic implementation; eclipse detection is complex
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest new moon before or on the given date.
|
||||
* Uses suncalc illumination fraction for precise minimum detection.
|
||||
*/
|
||||
function findPreviousNewMoon(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
let minIllum = 1
|
||||
let minDate = new Date(d)
|
||||
|
||||
for (let i = 0; i <= 30; i++) {
|
||||
const check = new Date(d)
|
||||
check.setDate(d.getDate() - i)
|
||||
const illum = SunCalc.getMoonIllumination(check).fraction
|
||||
if (illum < minIllum) {
|
||||
minIllum = illum
|
||||
minDate = new Date(check)
|
||||
}
|
||||
// If illumination is rising again and we passed a minimum, stop
|
||||
if (illum > minIllum + 0.05 && minIllum < 0.05) break
|
||||
}
|
||||
|
||||
return minDate
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next new moon after the given date.
|
||||
*/
|
||||
function findNextNewMoon(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + 1) // Start from tomorrow
|
||||
let minIllum = 1
|
||||
let minDate = new Date(d)
|
||||
|
||||
for (let i = 0; i <= 30; i++) {
|
||||
const check = new Date(d)
|
||||
check.setDate(d.getDate() + i)
|
||||
const illum = SunCalc.getMoonIllumination(check).fraction
|
||||
if (illum < minIllum) {
|
||||
minIllum = illum
|
||||
minDate = new Date(check)
|
||||
}
|
||||
if (illum > minIllum + 0.05 && minIllum < 0.05) break
|
||||
}
|
||||
|
||||
return minDate
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the synodic month containing the given date.
|
||||
* Returns the new moon boundaries and all 8 major phases.
|
||||
*/
|
||||
export function getSynodicMonth(date: Date): SynodicMonth {
|
||||
const startDate = findPreviousNewMoon(date)
|
||||
const endDate = findNextNewMoon(startDate)
|
||||
const durationDays = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
|
||||
// Calculate approximate dates for each major phase
|
||||
const phaseOffsets: Array<{ fraction: number; phase: LunarPhaseName }> = [
|
||||
{ fraction: 0, phase: 'new_moon' },
|
||||
{ fraction: 0.125, phase: 'waxing_crescent' },
|
||||
{ fraction: 0.25, phase: 'first_quarter' },
|
||||
{ fraction: 0.375, phase: 'waxing_gibbous' },
|
||||
{ fraction: 0.5, phase: 'full_moon' },
|
||||
{ fraction: 0.625, phase: 'waning_gibbous' },
|
||||
{ fraction: 0.75, phase: 'last_quarter' },
|
||||
{ fraction: 0.875, phase: 'waning_crescent' },
|
||||
]
|
||||
|
||||
const phases = phaseOffsets.map(({ fraction, phase }) => {
|
||||
const phaseDate = new Date(startDate.getTime() + fraction * durationDays * 24 * 60 * 60 * 1000)
|
||||
return {
|
||||
date: phaseDate,
|
||||
phase,
|
||||
emoji: PHASE_EMOJIS[phase],
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
durationDays,
|
||||
phases,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch compute lunar data for a date range.
|
||||
* Returns a Map keyed by ISO date string (YYYY-MM-DD).
|
||||
*/
|
||||
export function getLunarDataForRange(
|
||||
start: Date,
|
||||
end: Date
|
||||
): Map<string, LunarPhaseInfo> {
|
||||
const result = new Map<string, LunarPhaseInfo>()
|
||||
const current = new Date(start)
|
||||
|
||||
while (current <= end) {
|
||||
const key = current.toISOString().split('T')[0]
|
||||
result.set(key, getLunarPhaseForDate(current))
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get moonrise/moonset times for a specific date and location.
|
||||
* Uses suncalc for location-aware calculations.
|
||||
*/
|
||||
export function getMoonTimesForLocation(
|
||||
date: Date,
|
||||
lat: number,
|
||||
lng: number
|
||||
): { rise: Date | undefined; set: Date | undefined; alwaysUp: boolean; alwaysDown: boolean } {
|
||||
const times = SunCalc.getMoonTimes(date, lat, lng)
|
||||
return {
|
||||
rise: times.rise || undefined,
|
||||
set: times.set || undefined,
|
||||
alwaysUp: !!times.alwaysUp,
|
||||
alwaysDown: !!times.alwaysDown,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get moon position (altitude, azimuth, distance) for a specific date and location.
|
||||
*/
|
||||
export function getMoonPosition(
|
||||
date: Date,
|
||||
lat: number,
|
||||
lng: number
|
||||
): { altitude: number; azimuth: number; distance: number; parallacticAngle: number } {
|
||||
return SunCalc.getMoonPosition(date, lat, lng)
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
'use client'
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { CalendarType, ViewType, Location, SpatialGranularity, TabView, RCalContext } from '@/lib/types'
|
||||
import { TemporalGranularity, TEMPORAL_TO_VIEW, TEMPORAL_TO_SPATIAL } from '@/lib/types'
|
||||
|
||||
interface CalendarState {
|
||||
// Calendar type
|
||||
calendarType: CalendarType
|
||||
setCalendarType: (type: CalendarType) => void
|
||||
toggleCalendarType: () => void
|
||||
|
||||
// View type
|
||||
viewType: ViewType
|
||||
setViewType: (type: ViewType) => void
|
||||
|
||||
// Active tab
|
||||
activeTab: TabView
|
||||
setActiveTab: (tab: TabView) => 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
|
||||
showLunarOverlay: boolean
|
||||
setShowLunarOverlay: (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
|
||||
|
||||
// r* tool context
|
||||
rCalContext: RCalContext | null
|
||||
setRCalContext: (context: RCalContext | null) => void
|
||||
}
|
||||
|
||||
export const useCalendarStore = create<CalendarState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Calendar type
|
||||
calendarType: 'gregorian',
|
||||
setCalendarType: (calendarType) => set({ calendarType }),
|
||||
toggleCalendarType: () =>
|
||||
set((state) => ({
|
||||
calendarType: state.calendarType === 'gregorian' ? 'lunar' : 'gregorian',
|
||||
})),
|
||||
|
||||
// View type
|
||||
viewType: 'month',
|
||||
setViewType: (viewType) => set({ viewType }),
|
||||
|
||||
// Active tab
|
||||
activeTab: 'temporal',
|
||||
setActiveTab: (activeTab) => set({ activeTab }),
|
||||
|
||||
// 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
|
||||
showLunarOverlay: false,
|
||||
setShowLunarOverlay: (showLunarOverlay) => set({ showLunarOverlay }),
|
||||
|
||||
// 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] }),
|
||||
}
|
||||
}),
|
||||
|
||||
// r* tool context
|
||||
rCalContext: null,
|
||||
setRCalContext: (rCalContext) => set({ rCalContext }),
|
||||
}),
|
||||
{
|
||||
name: 'calendar-store',
|
||||
version: 3,
|
||||
partialize: (state) => ({
|
||||
calendarType: state.calendarType,
|
||||
viewType: state.viewType,
|
||||
activeTab: state.activeTab,
|
||||
temporalGranularity: state.temporalGranularity,
|
||||
spatialGranularity: state.spatialGranularity,
|
||||
showLunarOverlay: state.showLunarOverlay,
|
||||
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,287 @@
|
|||
// ── Calendar Types ──
|
||||
|
||||
export type CalendarType = 'gregorian' | 'lunar'
|
||||
|
||||
export type ViewType = 'month' | 'week' | 'day' | 'year' | 'timeline'
|
||||
|
||||
export type TabView = 'temporal' | 'spatial' | 'lunar' | 'context'
|
||||
|
||||
// ── Temporal Granularity ── zoom levels for time navigation
|
||||
|
||||
export enum TemporalGranularity {
|
||||
MOMENT = 0,
|
||||
HOUR = 1,
|
||||
DAY = 2,
|
||||
WEEK = 3,
|
||||
MONTH = 4,
|
||||
SEASON = 5,
|
||||
YEAR = 6,
|
||||
DECADE = 7,
|
||||
CENTURY = 8,
|
||||
COSMIC = 9,
|
||||
}
|
||||
|
||||
export const TEMPORAL_GRANULARITY_LABELS: Record<TemporalGranularity, string> = {
|
||||
[TemporalGranularity.MOMENT]: 'Moment',
|
||||
[TemporalGranularity.HOUR]: 'Hour',
|
||||
[TemporalGranularity.DAY]: 'Day',
|
||||
[TemporalGranularity.WEEK]: 'Week',
|
||||
[TemporalGranularity.MONTH]: 'Month',
|
||||
[TemporalGranularity.SEASON]: 'Season',
|
||||
[TemporalGranularity.YEAR]: 'Year',
|
||||
[TemporalGranularity.DECADE]: 'Decade',
|
||||
[TemporalGranularity.CENTURY]: 'Century',
|
||||
[TemporalGranularity.COSMIC]: 'Cosmic',
|
||||
}
|
||||
|
||||
export const TEMPORAL_TO_VIEW: Partial<Record<TemporalGranularity, ViewType>> = {
|
||||
[TemporalGranularity.DAY]: 'day',
|
||||
[TemporalGranularity.WEEK]: 'week',
|
||||
[TemporalGranularity.MONTH]: 'month',
|
||||
[TemporalGranularity.YEAR]: 'year',
|
||||
[TemporalGranularity.DECADE]: 'timeline',
|
||||
}
|
||||
|
||||
// ── Spatial Granularity ──
|
||||
|
||||
export enum SpatialGranularity {
|
||||
PLANET = 0,
|
||||
CONTINENT = 1,
|
||||
BIOREGION = 2,
|
||||
COUNTRY = 3,
|
||||
REGION = 4,
|
||||
CITY = 5,
|
||||
NEIGHBORHOOD = 6,
|
||||
ADDRESS = 7,
|
||||
COORDINATES = 8,
|
||||
}
|
||||
|
||||
export const GRANULARITY_LABELS: Record<SpatialGranularity, string> = {
|
||||
[SpatialGranularity.PLANET]: 'Planet',
|
||||
[SpatialGranularity.CONTINENT]: 'Continent',
|
||||
[SpatialGranularity.BIOREGION]: 'Bioregion',
|
||||
[SpatialGranularity.COUNTRY]: 'Country',
|
||||
[SpatialGranularity.REGION]: 'Region',
|
||||
[SpatialGranularity.CITY]: 'City',
|
||||
[SpatialGranularity.NEIGHBORHOOD]: 'Neighborhood',
|
||||
[SpatialGranularity.ADDRESS]: 'Address',
|
||||
[SpatialGranularity.COORDINATES]: 'Coordinates',
|
||||
}
|
||||
|
||||
// ── Temporal ↔ Spatial Coupling ──
|
||||
|
||||
export const TEMPORAL_TO_SPATIAL: Record<TemporalGranularity, SpatialGranularity> = {
|
||||
[TemporalGranularity.MOMENT]: SpatialGranularity.COORDINATES,
|
||||
[TemporalGranularity.HOUR]: SpatialGranularity.ADDRESS,
|
||||
[TemporalGranularity.DAY]: SpatialGranularity.ADDRESS,
|
||||
[TemporalGranularity.WEEK]: SpatialGranularity.CITY,
|
||||
[TemporalGranularity.MONTH]: SpatialGranularity.COUNTRY,
|
||||
[TemporalGranularity.SEASON]: SpatialGranularity.COUNTRY,
|
||||
[TemporalGranularity.YEAR]: SpatialGranularity.CONTINENT,
|
||||
[TemporalGranularity.DECADE]: SpatialGranularity.CONTINENT,
|
||||
[TemporalGranularity.CENTURY]: SpatialGranularity.PLANET,
|
||||
[TemporalGranularity.COSMIC]: SpatialGranularity.PLANET,
|
||||
}
|
||||
|
||||
export const SPATIAL_TO_LEAFLET_ZOOM: Record<SpatialGranularity, number> = {
|
||||
[SpatialGranularity.PLANET]: 2,
|
||||
[SpatialGranularity.CONTINENT]: 4,
|
||||
[SpatialGranularity.BIOREGION]: 5,
|
||||
[SpatialGranularity.COUNTRY]: 6,
|
||||
[SpatialGranularity.REGION]: 8,
|
||||
[SpatialGranularity.CITY]: 11,
|
||||
[SpatialGranularity.NEIGHBORHOOD]: 14,
|
||||
[SpatialGranularity.ADDRESS]: 16,
|
||||
[SpatialGranularity.COORDINATES]: 18,
|
||||
}
|
||||
|
||||
export function leafletZoomToSpatial(zoom: number): SpatialGranularity {
|
||||
if (zoom <= 2) return SpatialGranularity.PLANET
|
||||
if (zoom <= 4) return SpatialGranularity.CONTINENT
|
||||
if (zoom <= 5) return SpatialGranularity.BIOREGION
|
||||
if (zoom <= 7) return SpatialGranularity.COUNTRY
|
||||
if (zoom <= 9) return SpatialGranularity.REGION
|
||||
if (zoom <= 12) return SpatialGranularity.CITY
|
||||
if (zoom <= 15) return SpatialGranularity.NEIGHBORHOOD
|
||||
if (zoom <= 17) return SpatialGranularity.ADDRESS
|
||||
return SpatialGranularity.COORDINATES
|
||||
}
|
||||
|
||||
// ── Location Types ──
|
||||
|
||||
export interface Location {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
granularity: SpatialGranularity
|
||||
granularity_display: string
|
||||
parent: string | null
|
||||
path: string
|
||||
depth: number
|
||||
breadcrumb: string
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
coordinates: { latitude: number; longitude: number } | null
|
||||
timezone_str: string
|
||||
children_count: number
|
||||
}
|
||||
|
||||
// ── Calendar Source Types ──
|
||||
|
||||
export interface CalendarSource {
|
||||
id: string
|
||||
name: string
|
||||
source_type: 'google' | 'ics' | 'caldav' | 'outlook' | 'apple' | 'manual' | 'obsidian'
|
||||
source_type_display: string
|
||||
color: string
|
||||
is_visible: boolean
|
||||
is_active: boolean
|
||||
last_synced_at: string | null
|
||||
sync_error: string
|
||||
event_count: number
|
||||
}
|
||||
|
||||
// ── Lunar Types (replaces IFC) ──
|
||||
|
||||
export type LunarPhaseName =
|
||||
| 'new_moon'
|
||||
| 'waxing_crescent'
|
||||
| 'first_quarter'
|
||||
| 'waxing_gibbous'
|
||||
| 'full_moon'
|
||||
| 'waning_gibbous'
|
||||
| 'last_quarter'
|
||||
| 'waning_crescent'
|
||||
|
||||
export interface LunarPhaseInfo {
|
||||
phase: LunarPhaseName
|
||||
emoji: string
|
||||
illumination: number // 0-1
|
||||
age: number // Days into synodic cycle (0-29.53)
|
||||
isEclipse: boolean
|
||||
eclipseType?: 'solar' | 'lunar' | 'penumbral'
|
||||
}
|
||||
|
||||
export interface SynodicMonth {
|
||||
startDate: Date // New moon
|
||||
endDate: Date // Next new moon
|
||||
durationDays: number // ~29.53
|
||||
phases: Array<{
|
||||
date: Date
|
||||
phase: LunarPhaseName
|
||||
emoji: string
|
||||
}>
|
||||
}
|
||||
|
||||
// ── r* Tool Integration Types ──
|
||||
|
||||
export type RToolName = 'rTrips' | 'rNetwork' | 'rMaps' | 'rCart' | 'rNotes' | 'standalone'
|
||||
|
||||
export interface RCalContext {
|
||||
tool: RToolName
|
||||
entityId?: string
|
||||
entityType?: string
|
||||
dateRange?: { start: Date; end: Date }
|
||||
highlights?: string[]
|
||||
filters?: Record<string, unknown>
|
||||
viewConfig?: {
|
||||
defaultTab?: TabView
|
||||
defaultGranularity?: TemporalGranularity
|
||||
showMap?: boolean
|
||||
readOnly?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event Types ──
|
||||
|
||||
export interface UnifiedEvent {
|
||||
id: string
|
||||
source: string
|
||||
source_name: string
|
||||
source_color: string
|
||||
source_type: string
|
||||
external_id: string
|
||||
title: string
|
||||
description: string
|
||||
// Gregorian time
|
||||
start: string
|
||||
end: string
|
||||
all_day: boolean
|
||||
timezone_str: string
|
||||
rrule: string
|
||||
is_recurring: boolean
|
||||
// Location
|
||||
location: string | null
|
||||
location_raw: string
|
||||
location_display: string | null
|
||||
location_breadcrumb: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
coordinates: { latitude: number; longitude: number } | null
|
||||
location_granularity: SpatialGranularity | null
|
||||
// Virtual
|
||||
is_virtual: boolean
|
||||
virtual_url: string
|
||||
virtual_platform: string
|
||||
// Participants
|
||||
organizer_name: string
|
||||
organizer_email: string
|
||||
attendees: Array<{ email: string; name?: string; status?: string }>
|
||||
attendee_count: number
|
||||
// Status
|
||||
status: 'confirmed' | 'tentative' | 'cancelled'
|
||||
visibility: 'default' | 'public' | 'private'
|
||||
// Computed
|
||||
duration_minutes: number
|
||||
is_upcoming: boolean
|
||||
is_ongoing: boolean
|
||||
}
|
||||
|
||||
export interface EventListItem {
|
||||
id: string
|
||||
title: string
|
||||
start: string
|
||||
end: string
|
||||
all_day: boolean
|
||||
source: string
|
||||
source_color: string
|
||||
location_raw: string
|
||||
is_virtual: boolean
|
||||
status: string
|
||||
// Lunar data (computed client-side)
|
||||
lunarPhase?: LunarPhaseName
|
||||
lunarEmoji?: string
|
||||
}
|
||||
|
||||
export interface RCalEvent extends UnifiedEvent {
|
||||
lunarPhase?: LunarPhaseInfo
|
||||
rToolSource?: RToolName
|
||||
rToolEntityId?: string
|
||||
eventCategory?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ── Calendar View Types ──
|
||||
|
||||
export interface CalendarDay {
|
||||
date: Date
|
||||
day: number
|
||||
isCurrentMonth: boolean
|
||||
isToday: boolean
|
||||
events: EventListItem[]
|
||||
lunar?: LunarPhaseInfo
|
||||
}
|
||||
|
||||
export interface CalendarMonth {
|
||||
year: number
|
||||
month: number
|
||||
month_name: string
|
||||
days: CalendarDay[]
|
||||
}
|
||||
|
||||
// ── Map Types ──
|
||||
|
||||
export interface MapState {
|
||||
center: [number, number] // [lat, lng]
|
||||
zoom: number
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
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: {
|
||||
lunar: {
|
||||
new: '#1e1b4b', // Deep indigo (new moon - dark)
|
||||
waxing: '#4338ca', // Indigo (waxing crescent)
|
||||
first: '#6366f1', // Indigo-lighter (first quarter)
|
||||
gibbous: '#818cf8', // Light indigo (waxing gibbous)
|
||||
full: '#fbbf24', // Amber (full moon - bright)
|
||||
waning: '#a78bfa', // Violet (waning gibbous)
|
||||
last: '#7c3aed', // Purple (last quarter)
|
||||
crescent: '#4c1d95', // Deep purple (waning crescent)
|
||||
eclipse: '#dc2626', // Red (eclipses)
|
||||
},
|
||||
},
|
||||
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