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