zoomcal-jeffemmett/src/components/calendar/YearView.tsx

719 lines
24 KiB
TypeScript

'use client'
import { useMemo, useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useCalendarStore } from '@/lib/store'
import {
gregorianToIFC,
getIFCMonthColor,
isLeapYear,
IFC_MONTHS,
TemporalGranularity,
} from '@cal/shared'
import { clsx } from 'clsx'
type ViewMode = 'compact' | 'glance'
// Vibrant month colors for Gregorian calendar
const MONTH_COLORS = [
'#3B82F6', // January - Blue
'#EC4899', // February - Pink
'#10B981', // March - Emerald
'#F59E0B', // April - Amber
'#84CC16', // May - Lime
'#F97316', // June - Orange
'#EF4444', // July - Red
'#8B5CF6', // August - Violet
'#14B8A6', // September - Teal
'#D97706', // October - Amber Dark
'#A855F7', // November - Purple
'#0EA5E9', // December - Sky Blue
]
// Compact mini-month for the grid overview
interface MiniMonthProps {
year: number
month: number
calendarType: 'gregorian' | 'ifc'
isCurrentMonth: boolean
onMonthClick: (month: number) => void
eventCounts?: Record<number, number>
}
function MiniMonth({
year,
month,
calendarType,
isCurrentMonth,
onMonthClick,
eventCounts = {},
}: MiniMonthProps) {
const monthData = useMemo(() => {
if (calendarType === 'ifc') {
const days: { day: number; isToday: boolean }[] = []
const today = gregorianToIFC(new Date())
for (let day = 1; day <= 28; day++) {
days.push({
day,
isToday: today.year === year && today.month === month && today.day === day,
})
}
return { days, startDay: 0 }
} else {
const firstDay = new Date(year, month - 1, 1)
const lastDay = new Date(year, month, 0)
const daysInMonth = lastDay.getDate()
const startDay = firstDay.getDay()
const today = new Date()
today.setHours(0, 0, 0, 0)
const days: { day: number; isToday: boolean }[] = []
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month - 1, day)
days.push({
day,
isToday: date.getTime() === today.getTime(),
})
}
return { days, startDay }
}
}, [year, month, calendarType])
const monthName = calendarType === 'ifc'
? IFC_MONTHS[month - 1]
: new Date(year, month - 1).toLocaleDateString('en-US', { month: 'short' })
const monthColor = calendarType === 'ifc' ? getIFCMonthColor(month) : MONTH_COLORS[month - 1]
const maxEvents = Math.max(...Object.values(eventCounts), 1)
const getHeatColor = (count: number) => {
if (count === 0) return undefined
const intensity = Math.min(count / maxEvents, 1)
return `rgba(59, 130, 246, ${0.2 + intensity * 0.6})`
}
return (
<div
onClick={() => onMonthClick(month)}
className={clsx(
'p-2 rounded-lg cursor-pointer transition-all duration-200',
'hover:scale-105 hover:shadow-lg',
isCurrentMonth
? 'ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700'
)}
style={{
borderTop: `3px solid ${monthColor}`,
}}
>
<div
className="text-xs font-semibold mb-1 text-center"
style={{ color: monthColor }}
>
{monthName}
</div>
<div className="grid grid-cols-7 gap-px mb-0.5">
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
<div key={`${month}-header-${i}`} className="text-[8px] text-gray-400 text-center">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-px">
{Array.from({ length: monthData.startDay }).map((_, i) => (
<div key={`${month}-empty-${i}`} className="w-4 h-4" />
))}
{monthData.days.map(({ day, isToday }) => {
const eventCount = eventCounts[day] || 0
return (
<div
key={`${month}-day-${day}`}
className={clsx(
'w-4 h-4 text-[9px] flex items-center justify-center rounded-sm',
isToday
? 'bg-blue-500 text-white font-bold'
: 'text-gray-600 dark:text-gray-400'
)}
style={{
backgroundColor: isToday ? undefined : getHeatColor(eventCount),
}}
title={eventCount > 0 ? `${eventCount} event${eventCount > 1 ? 's' : ''}` : undefined}
>
{day}
</div>
)
})}
</div>
</div>
)
}
// Convert Sunday=0 to Monday=0 indexing
function toMondayFirst(dayOfWeek: number): number {
return dayOfWeek === 0 ? 6 : dayOfWeek - 1
}
// Weekday labels starting with Monday
const WEEKDAY_LABELS_MONDAY_FIRST = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
// Glance-style vertical month column - FULLSCREEN version with weekday alignment
interface GlanceMonthColumnProps {
year: number
month: number
calendarType: 'gregorian' | 'ifc'
onDayClick?: (date: Date) => void
}
type CalendarSlot = {
type: 'day'
day: number
date: Date
isToday: boolean
dayOfWeek: number // Monday=0, Sunday=6
} | {
type: 'empty'
dayOfWeek: number
}
function GlanceMonthColumn({ year, month, calendarType, onDayClick }: GlanceMonthColumnProps) {
const today = new Date()
today.setHours(0, 0, 0, 0)
// Build a 6-week (42 slot) grid aligned by weekday
const calendarGrid = useMemo(() => {
const slots: CalendarSlot[] = []
if (calendarType === 'ifc') {
// IFC: Always 28 days, always starts on Sunday (which is day 6 in Monday-first)
const todayIFC = gregorianToIFC(today)
// IFC months always start on Sunday, which is position 6 in Monday-first
// Add 6 empty slots for Mon-Sat before Sunday
for (let i = 0; i < 6; i++) {
slots.push({ type: 'empty', dayOfWeek: i })
}
for (let day = 1; day <= 28; day++) {
const ifcDayOfYear = (month - 1) * 28 + day
let gregorianDayOfYear = ifcDayOfYear
if (isLeapYear(year) && ifcDayOfYear >= 169) {
gregorianDayOfYear += 1
}
const date = new Date(year, 0, gregorianDayOfYear)
const dayOfWeek = (day - 1) % 7 // IFC: 0=Sun, 1=Mon... but we need Monday-first
const mondayFirst = dayOfWeek === 0 ? 6 : dayOfWeek - 1
slots.push({
type: 'day',
day,
date,
isToday: todayIFC.year === year && todayIFC.month === month && todayIFC.day === day,
dayOfWeek: mondayFirst,
})
}
// IFC 28 days + 6 leading = 34 slots, pad to 35 (5 weeks)
while (slots.length < 35) {
slots.push({ type: 'empty', dayOfWeek: slots.length % 7 })
}
} else {
// Gregorian: Variable days, variable start day
const firstDay = new Date(year, month - 1, 1)
const lastDay = new Date(year, month, 0)
const daysInMonth = lastDay.getDate()
const startDayOfWeek = toMondayFirst(firstDay.getDay())
// Add empty slots before the 1st
for (let i = 0; i < startDayOfWeek; i++) {
slots.push({ type: 'empty', dayOfWeek: i })
}
// Add all days of the month
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month - 1, day)
slots.push({
type: 'day',
day,
date,
isToday: date.getTime() === today.getTime(),
dayOfWeek: toMondayFirst(date.getDay()),
})
}
// Pad to complete the final week (to 35 or 42 slots for 5-6 weeks)
const targetSlots = slots.length <= 35 ? 35 : 42
while (slots.length < targetSlots) {
slots.push({ type: 'empty', dayOfWeek: slots.length % 7 })
}
}
return slots
}, [year, month, calendarType, today])
const monthName = calendarType === 'ifc'
? IFC_MONTHS[month - 1]
: new Date(year, month - 1).toLocaleDateString('en-US', { month: 'short' })
const monthColor = calendarType === 'ifc' ? getIFCMonthColor(month) : MONTH_COLORS[month - 1]
return (
<div className="flex flex-col flex-1 min-w-[7rem]">
{/* Month header */}
<div
className="text-center py-2 font-bold text-white flex-shrink-0"
style={{ backgroundColor: monthColor }}
>
<div className="text-base">{monthName}</div>
</div>
{/* Days grid - aligned by weekday */}
<div
className="flex-1 flex flex-col border-x border-b overflow-hidden"
style={{ borderColor: `${monthColor}40` }}
>
{calendarGrid.map((slot, idx) => {
const isWeekend = slot.dayOfWeek >= 5 // Saturday=5, Sunday=6
const isSunday = slot.dayOfWeek === 6
const isMonday = slot.dayOfWeek === 0
if (slot.type === 'empty') {
return (
<div
key={`empty-${idx}`}
className={clsx(
'flex-1 min-h-[1.25rem] w-full flex items-center gap-2 px-2 border-b last:border-b-0',
isSunday && 'bg-red-50/50 dark:bg-red-900/10',
isWeekend && !isSunday && 'bg-blue-50/50 dark:bg-blue-900/10',
!isWeekend && 'bg-gray-50 dark:bg-gray-800/50',
isMonday && 'border-t-2 border-t-gray-200 dark:border-t-gray-600'
)}
style={{ borderColor: `${monthColor}20` }}
>
<span className="text-[10px] font-medium w-5 flex-shrink-0 text-gray-300 dark:text-gray-600">
{WEEKDAY_LABELS_MONDAY_FIRST[slot.dayOfWeek]}
</span>
</div>
)
}
return (
<button
key={slot.day}
onClick={() => onDayClick?.(slot.date)}
className={clsx(
'flex-1 min-h-[1.25rem] w-full flex items-center gap-2 px-2 transition-all',
'hover:brightness-95 border-b last:border-b-0',
slot.isToday && 'ring-2 ring-inset font-bold',
isSunday && !slot.isToday && 'bg-red-50 dark:bg-red-900/20',
isWeekend && !isSunday && !slot.isToday && 'bg-blue-50 dark:bg-blue-900/20',
!isWeekend && !slot.isToday && 'bg-white dark:bg-gray-900',
isMonday && 'border-t-2 border-t-gray-200 dark:border-t-gray-600'
)}
style={{
borderColor: `${monthColor}20`,
...(slot.isToday && {
backgroundColor: monthColor,
color: 'white',
ringColor: 'white',
}),
}}
>
<span className={clsx(
'text-[10px] font-medium w-5 flex-shrink-0',
isSunday && !slot.isToday && 'text-red-500',
isWeekend && !isSunday && !slot.isToday && 'text-blue-500',
!isWeekend && !slot.isToday && 'text-gray-400 dark:text-gray-500'
)}>
{WEEKDAY_LABELS_MONDAY_FIRST[slot.dayOfWeek]}
</span>
<span className={clsx(
'text-sm font-semibold w-5 flex-shrink-0',
!slot.isToday && 'text-gray-800 dark:text-gray-200'
)}>
{slot.day}
</span>
{/* Space for events/location */}
<span className="flex-1 text-xs text-gray-500 dark:text-gray-400 truncate text-left">
{/* Future: event/location info here */}
</span>
</button>
)
})}
</div>
</div>
)
}
// Special day card for IFC in Glance view
function GlanceSpecialDay({ type, year, color }: { type: 'year-day' | 'leap-day'; year: number; color: string }) {
const isYearDay = type === 'year-day'
const today = new Date()
const isToday = isYearDay
? today.getMonth() === 11 && today.getDate() === 31 && today.getFullYear() === year
: isLeapYear(year) && today.getMonth() === 5 && today.getDate() === 17 && today.getFullYear() === year
return (
<div className="flex flex-col min-w-[5rem]">
<div
className="text-center py-2 font-bold text-white flex-shrink-0"
style={{ backgroundColor: color }}
>
<div className="text-sm">{isYearDay ? '✨' : '🌟'}</div>
</div>
<div
className={clsx(
'flex-1 border-x border-b flex items-center justify-center p-3',
isToday && 'ring-2 ring-amber-500'
)}
style={{
borderColor: `${color}40`,
backgroundColor: isToday ? color : `${color}10`,
}}
>
<div className="text-center">
<div className={clsx(
'text-sm font-bold',
isToday ? 'text-white' : 'text-gray-800 dark:text-gray-200'
)}>
{isYearDay ? 'Year Day' : 'Leap Day'}
</div>
<div className={clsx(
'text-xs mt-1',
isToday ? 'text-white/80' : 'text-gray-500'
)}>
{isYearDay ? 'Dec 31' : 'Jun 17'}
</div>
</div>
</div>
</div>
)
}
// Special day cards for IFC in Compact view
function SpecialDayCard({ type, year }: { type: 'year-day' | 'leap-day'; year: number }) {
const isYearDay = type === 'year-day'
const today = new Date()
const isToday = isYearDay
? today.getMonth() === 11 && today.getDate() === 31 && today.getFullYear() === year
: isLeapYear(year) && today.getMonth() === 5 && today.getDate() === 17 && today.getFullYear() === year
return (
<div
className={clsx(
'p-2 rounded-lg text-center',
isToday
? 'ring-2 ring-amber-500 bg-amber-50 dark:bg-amber-900/20'
: 'bg-gradient-to-br from-amber-100 to-orange-100 dark:from-amber-900/30 dark:to-orange-900/30'
)}
>
<div className="text-xs font-bold text-amber-700 dark:text-amber-400">
{isYearDay ? 'Year Day' : 'Leap Day'}
</div>
<div className="text-[10px] text-amber-600 dark:text-amber-500 mt-0.5">
{isYearDay ? 'Dec 31' : 'Jun 17'}
</div>
</div>
)
}
// Fullscreen Glance View Portal
interface FullscreenGlanceProps {
year: number
months: number
calendarType: 'gregorian' | 'ifc'
leap: boolean
onDayClick: (date: Date) => void
onClose: () => void
navigatePrev: () => void
navigateNext: () => void
}
function FullscreenGlance({
year,
months,
calendarType,
leap,
onDayClick,
onClose,
navigatePrev,
navigateNext,
}: FullscreenGlanceProps) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
// Handle escape key
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
navigatePrev()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
navigateNext()
}
}
// Prevent body scroll
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
return () => {
document.body.style.overflow = ''
window.removeEventListener('keydown', handleKeyDown)
}
}, [onClose, navigatePrev, navigateNext])
if (!mounted) return null
const content = (
<div className="fixed inset-0 z-50 flex flex-col bg-gradient-to-br from-slate-100 via-blue-50 to-purple-50 dark:from-gray-900 dark:via-slate-900 dark:to-gray-900">
{/* Header */}
<div className="flex items-center justify-between px-6 py-3 flex-shrink-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-4">
<button
onClick={navigatePrev}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title="Previous year (←)"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
{year}
</h1>
<button
onClick={navigateNext}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title="Next year (→)"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<span className="text-sm text-gray-500 dark:text-gray-400 bg-white/50 dark:bg-gray-800/50 px-3 py-1 rounded-full">
{calendarType === 'ifc' ? 'International Fixed Calendar' : 'Gregorian Calendar'}
</span>
</div>
<button
onClick={onClose}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-sm"
title="Exit fullscreen (Esc)"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Exit Fullscreen
</button>
</div>
{/* Month columns - takes up remaining space */}
<div className="flex-1 flex gap-1 p-3 overflow-x-auto min-h-0">
{Array.from({ length: months }).map((_, i) => (
<GlanceMonthColumn
key={i + 1}
year={year}
month={i + 1}
calendarType={calendarType}
onDayClick={onDayClick}
/>
))}
{/* IFC Special days */}
{calendarType === 'ifc' && (
<>
<GlanceSpecialDay type="year-day" year={year} color="#F59E0B" />
{leap && <GlanceSpecialDay type="leap-day" year={year} color="#8B5CF6" />}
</>
)}
</div>
{/* Footer */}
<div className="text-center text-xs text-gray-500 dark:text-gray-400 py-2 bg-white/50 dark:bg-gray-900/50 backdrop-blur flex-shrink-0">
Click any day to zoom in navigate years Esc to exit T for today
</div>
</div>
)
return createPortal(content, document.body)
}
export function YearView() {
const [viewMode, setViewMode] = useState<ViewMode>('glance')
const {
currentDate,
calendarType,
setCurrentDate,
setViewType,
setTemporalGranularity,
navigateByGranularity,
} = useCalendarStore()
const year = currentDate.getFullYear()
const currentMonth = currentDate.getMonth() + 1
const currentIFC = gregorianToIFC(currentDate)
const handleMonthClick = (month: number) => {
if (calendarType === 'ifc') {
const ifcDayOfYear = (month - 1) * 28 + 1
let gregorianDayOfYear = ifcDayOfYear
if (isLeapYear(year) && ifcDayOfYear >= 169) {
gregorianDayOfYear += 1
}
const targetDate = new Date(year, 0, gregorianDayOfYear)
setCurrentDate(targetDate)
} else {
setCurrentDate(new Date(year, month - 1, 1))
}
setTemporalGranularity(TemporalGranularity.MONTH)
setViewType('month')
}
const handleDayClick = (date: Date) => {
setCurrentDate(date)
setTemporalGranularity(TemporalGranularity.DAY)
}
const months = calendarType === 'ifc' ? 13 : 12
const leap = isLeapYear(year)
// Mock event counts for compact view
const mockEventCounts = useMemo(() => {
const counts: Record<number, Record<number, number>> = {}
for (let m = 1; m <= months; m++) {
counts[m] = {}
for (let d = 1; d <= 28; d++) {
if (Math.random() > 0.7) {
counts[m][d] = Math.floor(Math.random() * 5) + 1
}
}
}
return counts
}, [months])
// Glance mode uses fullscreen portal
if (viewMode === 'glance') {
return (
<>
{/* Placeholder in normal flow */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center">
<div className="text-gray-500 dark:text-gray-400">
<div className="text-lg font-medium mb-2">Glance View Active</div>
<div className="text-sm">Fullscreen calendar is displayed. Press Esc to return.</div>
</div>
</div>
{/* Fullscreen portal */}
<FullscreenGlance
year={year}
months={months}
calendarType={calendarType}
leap={leap}
onDayClick={handleDayClick}
onClose={() => setViewMode('compact')}
navigatePrev={() => navigateByGranularity('prev')}
navigateNext={() => navigateByGranularity('next')}
/>
</>
)
}
// Compact view - traditional grid layout
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
{/* Year header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{year}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{calendarType === 'ifc'
? `International Fixed Calendar • ${months} months`
: 'Gregorian Calendar'
}
</p>
</div>
{/* View mode toggle */}
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setViewMode('compact')}
className="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white dark:bg-gray-600 shadow-sm"
>
Compact
</button>
<button
onClick={() => setViewMode('glance')}
className="px-3 py-1.5 text-xs font-medium rounded-md transition-colors text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
Fullscreen
</button>
</div>
</div>
{/* Month grid */}
<div className="p-4">
<div
className={clsx(
'grid gap-3',
calendarType === 'ifc' ? 'grid-cols-4' : 'grid-cols-4 md:grid-cols-6'
)}
>
{Array.from({ length: months }).map((_, i) => {
const month = i + 1
const isCurrentMonthView = calendarType === 'ifc'
? currentIFC.month === month && currentIFC.year === year
: currentMonth === month && currentDate.getFullYear() === year
return (
<MiniMonth
key={`month-${month}`}
year={year}
month={month}
calendarType={calendarType}
isCurrentMonth={isCurrentMonthView}
onMonthClick={handleMonthClick}
eventCounts={mockEventCounts[month]}
/>
)
})}
{calendarType === 'ifc' && (
<>
<SpecialDayCard type="year-day" year={year} />
{leap && <SpecialDayCard type="leap-day" year={year} />}
</>
)}
</div>
</div>
{/* Legend */}
<div className="px-4 pb-4">
<div className="flex items-center justify-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-blue-500" />
<span>Today</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/20" />
<span>Current month</span>
</div>
</div>
<p className="text-xs text-center text-gray-400 dark:text-gray-500 mt-2">
Click any month to zoom in Click &quot;Fullscreen&quot; for year-at-a-glance
</p>
</div>
</div>
)
}