feat: initial cal-shared package with types, API client, IFC logic, and hooks
Shared package for cal.jeffemmett.com and zoomcal.jeffemmett.com containing: - TypeScript types and enums (calendar, spatial, temporal) - API client functions (events, sources, locations, IFC) - IFC calendar conversion utilities - Semantic location label utilities - React Query hooks (useEvents, useMonthEvents, useSources) - QueryClientProvider setup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
278a31a650
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "@cal/shared",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared types, API client, IFC logic, and hooks for cal and zoomcal",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"@tanstack/react-query": "^5.17.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getEvents, getSources } from '../lib/api'
|
||||
import { EventListItem, CalendarSource } from '../types'
|
||||
|
||||
interface EventsResponse {
|
||||
count: number
|
||||
next: string | null
|
||||
previous: string | null
|
||||
results: EventListItem[]
|
||||
}
|
||||
|
||||
interface SourcesResponse {
|
||||
count: number
|
||||
next: string | null
|
||||
previous: string | null
|
||||
results: CalendarSource[]
|
||||
}
|
||||
|
||||
export function useEvents(params?: {
|
||||
start?: string
|
||||
end?: string
|
||||
source?: string
|
||||
ifc_month?: number
|
||||
ifc_year?: number
|
||||
}) {
|
||||
return useQuery<EventsResponse>({
|
||||
queryKey: ['events', params],
|
||||
queryFn: () => getEvents(params) as Promise<EventsResponse>,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})
|
||||
}
|
||||
|
||||
export function useMonthEvents(year: number, month: number) {
|
||||
// Get first and last day of month
|
||||
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 }) as Promise<EventsResponse>,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSources() {
|
||||
return useQuery<SourcesResponse>({
|
||||
queryKey: ['sources'],
|
||||
queryFn: () => getSources() as Promise<SourcesResponse>,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
})
|
||||
}
|
||||
|
||||
// Group events by date for calendar display
|
||||
export function groupEventsByDate(events: EventListItem[]): Map<string, EventListItem[]> {
|
||||
const grouped = new Map<string, EventListItem[]>()
|
||||
|
||||
for (const event of events) {
|
||||
const dateKey = event.start.split('T')[0]
|
||||
const existing = grouped.get(dateKey) || []
|
||||
existing.push(event)
|
||||
grouped.set(dateKey, existing)
|
||||
}
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Types
|
||||
export * from './types'
|
||||
|
||||
// Lib
|
||||
export * from './lib/ifc'
|
||||
export * from './lib/api'
|
||||
export * from './lib/location'
|
||||
|
||||
// Hooks
|
||||
export * from './hooks/useEvents'
|
||||
|
||||
// Providers
|
||||
export { Providers } from './providers'
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1'
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// Events API
|
||||
|
||||
export async function getEvents(params?: {
|
||||
start?: string
|
||||
end?: string
|
||||
source?: string
|
||||
location?: string
|
||||
ifc_month?: number
|
||||
ifc_year?: number
|
||||
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(`/events${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export async function getEvent(id: string) {
|
||||
return fetcher(`/events/${id}/`)
|
||||
}
|
||||
|
||||
export async function getUpcomingEvents(days = 7) {
|
||||
return fetcher(`/events/upcoming/?days=${days}`)
|
||||
}
|
||||
|
||||
export async function getTodayEvents() {
|
||||
return fetcher(`/events/today/`)
|
||||
}
|
||||
|
||||
export async function getEventsByIFCMonth(year: number, month?: number) {
|
||||
const params = month ? `?year=${year}&month=${month}` : `?year=${year}`
|
||||
return fetcher(`/events/by_ifc_month/${params}`)
|
||||
}
|
||||
|
||||
export async function getCalendarView(params: {
|
||||
year: number
|
||||
month: number
|
||||
type?: 'gregorian' | 'ifc'
|
||||
}) {
|
||||
const searchParams = new URLSearchParams({
|
||||
year: String(params.year),
|
||||
month: String(params.month),
|
||||
type: params.type || 'gregorian',
|
||||
})
|
||||
return fetcher(`/events/calendar_view/?${searchParams}`)
|
||||
}
|
||||
|
||||
// Sources API
|
||||
|
||||
export async function getSources(params?: {
|
||||
is_active?: boolean
|
||||
is_visible?: boolean
|
||||
source_type?: 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(`/sources${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export async function getSource(id: string) {
|
||||
return fetcher(`/sources/${id}/`)
|
||||
}
|
||||
|
||||
export async function syncSource(id: string) {
|
||||
return fetcher(`/sources/${id}/sync/`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function syncAllSources() {
|
||||
// Get all active sources and sync them in parallel
|
||||
const response = await getSources({ is_active: true }) as { results: Array<{ id: string }> }
|
||||
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 getLocation(slug: string) {
|
||||
return fetcher(`/locations/${slug}/`)
|
||||
}
|
||||
|
||||
export async function getLocationRoots() {
|
||||
return fetcher(`/locations/roots/`)
|
||||
}
|
||||
|
||||
export async function getLocationTree() {
|
||||
return fetcher(`/locations/tree/`)
|
||||
}
|
||||
|
||||
export async function getLocationChildren(slug: string) {
|
||||
return fetcher(`/locations/${slug}/children/`)
|
||||
}
|
||||
|
||||
export async function getLocationEvents(slug: string, includeDescendants = true) {
|
||||
return fetcher(`/locations/${slug}/events/?include_descendants=${includeDescendants}`)
|
||||
}
|
||||
|
||||
// IFC API
|
||||
|
||||
export async function convertToIFC(date: string) {
|
||||
return fetcher(`/ifc/convert/?direction=to_ifc&date=${date}`)
|
||||
}
|
||||
|
||||
export async function convertToGregorian(params: {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
is_year_day?: boolean
|
||||
is_leap_day?: boolean
|
||||
}) {
|
||||
const searchParams = new URLSearchParams({
|
||||
direction: 'to_gregorian',
|
||||
year: String(params.year),
|
||||
month: String(params.month),
|
||||
day: String(params.day),
|
||||
})
|
||||
if (params.is_year_day) searchParams.append('is_year_day', 'true')
|
||||
if (params.is_leap_day) searchParams.append('is_leap_day', 'true')
|
||||
|
||||
return fetcher(`/ifc/convert/?${searchParams}`)
|
||||
}
|
||||
|
||||
export async function getIFCCalendar(year: number, month?: number) {
|
||||
const params = month ? `?year=${year}&month=${month}` : `?year=${year}`
|
||||
return fetcher(`/ifc/calendar/${params}`)
|
||||
}
|
||||
|
||||
// Stats API
|
||||
|
||||
export async function getCalendarStats() {
|
||||
return fetcher(`/stats/`)
|
||||
}
|
||||
|
||||
// Granularity API
|
||||
|
||||
export async function getSpatialGranularities() {
|
||||
return fetcher(`/granularities/`)
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* International Fixed Calendar (IFC) utilities for client-side conversion.
|
||||
*
|
||||
* The IFC is a proposed calendar reform with:
|
||||
* - 13 months of 28 days each (364 days)
|
||||
* - 1 "Year Day" after December 28 (no weekday)
|
||||
* - 1 "Leap Day" after June 28 in leap years (no weekday)
|
||||
* - Every month starts on Sunday, ends on Saturday
|
||||
*/
|
||||
|
||||
import { IFCDate, IFC_MONTHS, IFC_WEEKDAYS } from '../types'
|
||||
|
||||
export function isLeapYear(year: number): boolean {
|
||||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
|
||||
}
|
||||
|
||||
export function getDayOfYear(date: Date): number {
|
||||
const start = new Date(date.getFullYear(), 0, 0)
|
||||
const diff = date.getTime() - start.getTime()
|
||||
const oneDay = 1000 * 60 * 60 * 24
|
||||
return Math.floor(diff / oneDay)
|
||||
}
|
||||
|
||||
export function gregorianToIFC(date: Date): IFCDate {
|
||||
const year = date.getFullYear()
|
||||
const dayOfYear = getDayOfYear(date)
|
||||
const leap = isLeapYear(year)
|
||||
|
||||
// Handle Year Day (last day of year - day 365 or 366)
|
||||
const lastDay = leap ? 366 : 365
|
||||
if (dayOfYear === lastDay) {
|
||||
return {
|
||||
year,
|
||||
month: 13,
|
||||
day: 29, // Special "29th" of December
|
||||
weekday: -1, // No weekday
|
||||
is_year_day: true,
|
||||
is_leap_day: false,
|
||||
month_name: 'Year Day',
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Leap Day (day 169 in leap years = Jun 17 Gregorian)
|
||||
if (leap && dayOfYear === 169) {
|
||||
return {
|
||||
year,
|
||||
month: 6, // After June
|
||||
day: 29, // Special "29th"
|
||||
weekday: -1, // No weekday
|
||||
is_year_day: false,
|
||||
is_leap_day: true,
|
||||
month_name: 'Leap Day',
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust day count: remove leap day from count if we're past it
|
||||
let adjustedDay = dayOfYear
|
||||
if (leap && dayOfYear > 169) {
|
||||
adjustedDay -= 1
|
||||
}
|
||||
|
||||
// Calculate IFC month and day
|
||||
const ifcMonth = Math.floor((adjustedDay - 1) / 28) + 1
|
||||
const ifcDay = ((adjustedDay - 1) % 28) + 1
|
||||
|
||||
// Weekday: In IFC, day 1 of every month is Sunday
|
||||
const ifcWeekday = (ifcDay - 1) % 7
|
||||
|
||||
return {
|
||||
year,
|
||||
month: ifcMonth,
|
||||
day: ifcDay,
|
||||
weekday: ifcWeekday,
|
||||
is_year_day: false,
|
||||
is_leap_day: false,
|
||||
month_name: IFC_MONTHS[ifcMonth - 1],
|
||||
}
|
||||
}
|
||||
|
||||
export function ifcToGregorian(ifc: IFCDate): Date {
|
||||
const year = ifc.year
|
||||
const leap = isLeapYear(year)
|
||||
|
||||
// Handle special days
|
||||
if (ifc.is_year_day) {
|
||||
return new Date(year, 11, 31) // Dec 31
|
||||
}
|
||||
|
||||
if (ifc.is_leap_day) {
|
||||
return new Date(year, 5, 17) // Jun 17
|
||||
}
|
||||
|
||||
// Calculate day of year from IFC month/day
|
||||
const ifcDayOfYear = (ifc.month - 1) * 28 + ifc.day
|
||||
|
||||
// Adjust for leap day if applicable
|
||||
let gregorianDayOfYear = ifcDayOfYear
|
||||
if (leap && ifcDayOfYear >= 169) {
|
||||
gregorianDayOfYear += 1
|
||||
}
|
||||
|
||||
// Convert day of year to date
|
||||
const result = new Date(year, 0, 1)
|
||||
result.setDate(gregorianDayOfYear)
|
||||
return result
|
||||
}
|
||||
|
||||
export function formatIFCDate(ifc: IFCDate, format: 'full' | 'medium' | 'short' | 'numeric' = 'full'): string {
|
||||
if (ifc.is_year_day) {
|
||||
if (format === 'numeric') return `${ifc.year}-YD`
|
||||
return `Year Day, ${ifc.year}`
|
||||
}
|
||||
|
||||
if (ifc.is_leap_day) {
|
||||
if (format === 'numeric') return `${ifc.year}-LD`
|
||||
return `Leap Day, ${ifc.year}`
|
||||
}
|
||||
|
||||
switch (format) {
|
||||
case 'full': {
|
||||
const weekday = ifc.weekday >= 0 ? IFC_WEEKDAYS[ifc.weekday] : ''
|
||||
return `${weekday}, ${ifc.month_name} ${ifc.day}, ${ifc.year}`
|
||||
}
|
||||
case 'medium': {
|
||||
const weekdayShort = ifc.weekday >= 0 ? IFC_WEEKDAYS[ifc.weekday].slice(0, 3) : ''
|
||||
const monthShort = ifc.month_name.slice(0, 3)
|
||||
return `${weekdayShort}, ${monthShort} ${ifc.day}, ${ifc.year}`
|
||||
}
|
||||
case 'short': {
|
||||
const monthShort = ifc.month_name.slice(0, 3)
|
||||
return `${monthShort} ${ifc.day}`
|
||||
}
|
||||
case 'numeric':
|
||||
return `${ifc.year}-${String(ifc.month).padStart(2, '0')}-${String(ifc.day).padStart(2, '0')}`
|
||||
default:
|
||||
return formatIFCDate(ifc, 'full')
|
||||
}
|
||||
}
|
||||
|
||||
export function getIFCMonthCalendar(year: number, month: number): IFCDate[][] {
|
||||
const grid: IFCDate[][] = []
|
||||
|
||||
for (let week = 0; week < 4; week++) {
|
||||
const weekDays: IFCDate[] = []
|
||||
for (let dayInWeek = 0; dayInWeek < 7; dayInWeek++) {
|
||||
const day = week * 7 + dayInWeek + 1
|
||||
weekDays.push({
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
weekday: dayInWeek,
|
||||
is_year_day: false,
|
||||
is_leap_day: false,
|
||||
month_name: IFC_MONTHS[month - 1],
|
||||
})
|
||||
}
|
||||
grid.push(weekDays)
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
|
||||
export function getIFCWeekdayName(weekday: number): string {
|
||||
if (weekday < 0) return 'N/A'
|
||||
return IFC_WEEKDAYS[weekday]
|
||||
}
|
||||
|
||||
export function getIFCMonthName(month: number): string {
|
||||
if (month >= 1 && month <= 13) {
|
||||
return IFC_MONTHS[month - 1]
|
||||
}
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
export function getTodayIFC(): IFCDate {
|
||||
return gregorianToIFC(new Date())
|
||||
}
|
||||
|
||||
// Get the IFC month color for styling
|
||||
export function getIFCMonthColor(month: number): string {
|
||||
const colors: Record<number, string> = {
|
||||
1: '#ef4444', // January - Red
|
||||
2: '#f97316', // February - Orange
|
||||
3: '#eab308', // March - Yellow
|
||||
4: '#22c55e', // April - Green
|
||||
5: '#14b8a6', // May - Teal
|
||||
6: '#0ea5e9', // June - Sky
|
||||
7: '#f59e0b', // Sol - Amber
|
||||
8: '#3b82f6', // July - Blue
|
||||
9: '#6366f1', // August - Indigo
|
||||
10: '#8b5cf6', // September - Violet
|
||||
11: '#a855f7', // October - Purple
|
||||
12: '#ec4899', // November - Pink
|
||||
13: '#78716c', // December - Stone
|
||||
}
|
||||
return colors[month] || '#6b7280'
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { SpatialGranularity, UnifiedEvent, EventListItem } from '../types'
|
||||
|
||||
/**
|
||||
* Returns the best location string for an event given the current spatial zoom level.
|
||||
*
|
||||
* At broad zooms (PLANET/CONTINENT), returns the continent/country from breadcrumb.
|
||||
* At medium zooms (COUNTRY/REGION/CITY), returns the city or country.
|
||||
* At fine zooms (NEIGHBORHOOD/ADDRESS/COORDINATES), returns the full address.
|
||||
*
|
||||
* Falls back gracefully through location_display → location_raw.
|
||||
*/
|
||||
export function getSemanticLocationLabel(
|
||||
event: UnifiedEvent | EventListItem,
|
||||
spatialGranularity: SpatialGranularity
|
||||
): string {
|
||||
// EventListItem only has location_raw
|
||||
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') : ''
|
||||
}
|
||||
|
||||
// Use breadcrumb for structured parsing
|
||||
// breadcrumb format: "Earth > Europe > Germany > Munich > Dachauer Str. 7"
|
||||
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.
|
||||
* Useful for clustering markers on the map at broad zoom levels.
|
||||
*/
|
||||
export function groupEventsByLocation(
|
||||
events: UnifiedEvent[],
|
||||
spatialGranularity: SpatialGranularity
|
||||
): Map<string, UnifiedEvent[]> {
|
||||
const grouped = new Map<string, UnifiedEvent[]>()
|
||||
|
||||
for (const event of events) {
|
||||
const label = getSemanticLocationLabel(event, spatialGranularity)
|
||||
if (!label) continue
|
||||
const existing = grouped.get(label) || []
|
||||
existing.push(event)
|
||||
grouped.set(label, existing)
|
||||
}
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
|
@ -0,0 +1,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, // 1 minute
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
// Calendar Types
|
||||
|
||||
export type CalendarType = 'gregorian' | 'ifc'
|
||||
|
||||
export type ViewType = 'month' | 'week' | 'day' | 'year' | 'timeline'
|
||||
|
||||
// Temporal Granularity - zoom levels for time navigation
|
||||
export enum TemporalGranularity {
|
||||
MOMENT = 0, // Real-time / Now view
|
||||
HOUR = 1, // Hourly schedule blocks
|
||||
DAY = 2, // Day planner
|
||||
WEEK = 3, // Week view
|
||||
MONTH = 4, // Month view (default)
|
||||
SEASON = 5, // Quarterly / 91-day IFC quarters
|
||||
YEAR = 6, // Annual glance view
|
||||
DECADE = 7, // 10-year strategic view
|
||||
CENTURY = 8, // Historical / long-term view
|
||||
COSMIC = 9, // Deep time / geological ages
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
// Map temporal granularity to ViewType for rendering
|
||||
export const TEMPORAL_TO_VIEW: Partial<Record<TemporalGranularity, ViewType>> = {
|
||||
[TemporalGranularity.DAY]: 'day',
|
||||
[TemporalGranularity.WEEK]: 'week',
|
||||
[TemporalGranularity.MONTH]: 'month',
|
||||
[TemporalGranularity.YEAR]: 'year',
|
||||
[TemporalGranularity.DECADE]: 'timeline',
|
||||
}
|
||||
|
||||
// IFC Season/Quarter definitions
|
||||
export interface IFCSeason {
|
||||
quarter: 1 | 2 | 3 | 4
|
||||
name: string
|
||||
months: number[] // IFC month numbers
|
||||
startDay: number // Day of year (1-364)
|
||||
endDay: number
|
||||
}
|
||||
|
||||
export const IFC_SEASONS: IFCSeason[] = [
|
||||
{ quarter: 1, name: 'Spring', months: [1, 2, 3], startDay: 1, endDay: 91 },
|
||||
{ quarter: 2, name: 'Summer', months: [4, 5, 6, 7], startDay: 92, endDay: 182 }, // Includes Sol
|
||||
{ quarter: 3, name: 'Autumn', months: [8, 9, 10], startDay: 183, endDay: 273 },
|
||||
{ quarter: 4, name: 'Winter', months: [11, 12, 13], startDay: 274, endDay: 364 },
|
||||
]
|
||||
|
||||
export interface IFCDate {
|
||||
year: number
|
||||
month: number // 1-13
|
||||
day: number // 1-28 (or 29 for special days)
|
||||
weekday: number // 0-6 (Sun-Sat), -1 for special days
|
||||
is_year_day: boolean
|
||||
is_leap_day: boolean
|
||||
month_name: string
|
||||
}
|
||||
|
||||
export const IFC_MONTHS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'Sol', // New month between June and July
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
] as const
|
||||
|
||||
export const IFC_WEEKDAYS = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
] as const
|
||||
|
||||
// Spatial Types
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
// IFC time
|
||||
ifc_start_year: number | null
|
||||
ifc_start_month: number | null
|
||||
ifc_start_day: number | null
|
||||
ifc_start_weekday: number | null
|
||||
ifc_is_year_day: boolean
|
||||
ifc_is_leap_day: boolean
|
||||
ifc_date: IFCDate | null
|
||||
ifc_display: string | null
|
||||
// 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
|
||||
ifc_start_month: number | null
|
||||
ifc_start_day: number | null
|
||||
ifc_display: string | null
|
||||
}
|
||||
|
||||
// Calendar View Types
|
||||
|
||||
export interface CalendarDay {
|
||||
date: Date
|
||||
day: number
|
||||
isCurrentMonth: boolean
|
||||
isToday: boolean
|
||||
events: EventListItem[]
|
||||
// IFC specific
|
||||
ifc?: IFCDate
|
||||
}
|
||||
|
||||
export interface CalendarMonth {
|
||||
year: number
|
||||
month: number
|
||||
month_name: string
|
||||
days: CalendarDay[]
|
||||
}
|
||||
|
||||
// ── Temporal → Spatial Coupling ──
|
||||
|
||||
/** Maps each TemporalGranularity to a "natural" SpatialGranularity for coupled zoom. */
|
||||
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,
|
||||
}
|
||||
|
||||
/** Maps SpatialGranularity to Leaflet zoom levels (0 = whole world, 18 = building). */
|
||||
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,
|
||||
}
|
||||
|
||||
/** Reverse mapping: Leaflet zoom → closest SpatialGranularity. */
|
||||
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
|
||||
}
|
||||
|
||||
/** Map state for the Leaflet map component. */
|
||||
export interface MapState {
|
||||
center: [number, number] // [lat, lng]
|
||||
zoom: number // Leaflet zoom level
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Reference in New Issue