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:
Jeff Emmett 2026-02-15 09:01:34 -07:00
commit 5fcbfd6fa8
58 changed files with 12234 additions and 0 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
node_modules
.next
.git
.gitignore
.env
.env.*
!.env.example
*.md
.vscode
.idea

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
# Database
DATABASE_URL=postgresql://rcal:rcal_password@localhost:5432/rcal
# Next.js
NEXT_TELEMETRY_DISABLED=1

42
.gitignore vendored Normal file
View File

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

52
Dockerfile Normal file
View File

@ -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"]

62
docker-compose.yml Normal file
View File

@ -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

7
next.config.js Normal file
View File

@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
}
module.exports = nextConfig

6713
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@ -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"
}
}

6
postcss.config.js Normal file
View File

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

102
prisma/schema.prisma Normal file
View File

@ -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")
}

1
public/.gitkeep Normal file
View File

@ -0,0 +1 @@
{}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View File

@ -0,0 +1,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 })
}
}

View File

@ -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 })
}
}

141
src/app/api/events/route.ts Normal file
View File

@ -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 })
}
}

View File

@ -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 }
)
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

94
src/app/context/page.tsx Normal file
View File

@ -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>
)
}

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

@ -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;
}

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

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

After

Width:  |  Height:  |  Size: 896 B

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

@ -0,0 +1,28 @@
import type { Metadata } from 'next'
import { Inter, JetBrains_Mono } from 'next/font/google'
import './globals.css'
import { Providers } from '@/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>
)
}

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

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
))}
</>
)
}

View File

@ -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>
)
}

View File

@ -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 }
}

View File

@ -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>
)
}

View File

@ -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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapController center={center} zoom={zoom} />
<EventMarkers events={events} />
</MapContainer>
{/* Overlay: granularity indicator + coupling toggle */}
<div className="absolute top-3 right-3 z-[1000] flex flex-col gap-2">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md px-3 py-2 text-xs">
<div className="text-gray-500 dark:text-gray-400">
{GRANULARITY_LABELS[spatialGranularity]}
</div>
</div>
<button
onClick={toggleZoomCoupled}
className={clsx(
'bg-white dark:bg-gray-800 rounded-lg shadow-md px-3 py-2 text-xs transition-colors',
zoomCoupled
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
)}
title={zoomCoupled ? 'Unlink spatial from temporal zoom' : 'Link spatial to temporal zoom'}
>
{zoomCoupled ? '🔗' : '🔓'}
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,27 @@
'use client'
import dynamic from 'next/dynamic'
import type { EventListItem } from '@/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} />
}

View File

@ -0,0 +1,38 @@
'use client'
import { Panel, Group, Separator } from 'react-resizable-panels'
import { useCalendarStore } from '@/lib/store'
import { SpatioTemporalMap } from './SpatioTemporalMapLoader'
import type { EventListItem } from '@/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>
)
}

View File

@ -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>
)
}

View File

@ -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 &quot;Fullscreen&quot; for year-at-a-glance
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,8 @@
export { MonthView } from './MonthView'
export { SeasonView } from './SeasonView'
export { YearView } from './YearView'
export { CalendarHeader } from './CalendarHeader'
export { CalendarSidebar } from './CalendarSidebar'
export { TemporalZoomController } from './TemporalZoomController'
export { SplitView } from './SplitView'
export { SpatioTemporalMap } from './SpatioTemporalMapLoader'

View File

@ -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}</>
}

View File

@ -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])
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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}
/>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

51
src/hooks/useEvents.ts Normal file
View File

@ -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
}

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

@ -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 }
}

163
src/lib/api.ts Normal file
View File

@ -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`)
}

123
src/lib/context-adapters.ts Normal file
View File

@ -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 []
},
})

45
src/lib/embed.tsx Normal file
View File

@ -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>
)
}

66
src/lib/location.ts Normal file
View File

@ -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
}

197
src/lib/lunar.ts Normal file
View File

@ -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)
}

13
src/lib/prisma.ts Normal file
View File

@ -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

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

@ -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]
})
}

287
src/lib/types.ts Normal file
View File

@ -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
}

24
src/providers/index.tsx Normal file
View File

@ -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>
)
}

33
tailwind.config.ts Normal file
View File

@ -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

26
tsconfig.json Normal file
View File

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