import { useState } from 'react'; import { useCalendar, useCalendarDate } from '../hooks/useCalendar'; import { useQuery } from '@tanstack/react-query'; import { api } from '../api'; import MoonPhaseIcon from '../components/sky/MoonPhaseIcon'; import { format, startOfMonth, endOfMonth, eachDayOfInterval } from 'date-fns'; const FILTER_LABELS: Record = { sv220: 'HaOIII', c2: 'SII/OIII', sv260: 'LP', uvir: 'UV/IR', }; function fmtTime(utc?: string): string { if (!utc) return '—'; return new Date(utc).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris', }); } type CalDay = { date: string; moon_illumination?: number; max_usable_min?: number; avg_max_alt?: number; }; /** 12-month horizontal lunar cycle timeline */ function NewMoonTimeline({ days }: { days: CalDay[] }) { const { data: nmData } = useQuery({ queryKey: ['new-moon-windows'], queryFn: () => api.calendar.getNewMoonWindows(), staleTime: 24 * 60 * 60_000, }); // Build a map of new moon date → top targets const nmTargetMap = new Map(); for (const w of nmData?.windows ?? []) { nmTargetMap.set(w.date, w.top_targets); } if (days.length === 0) { return
Loading…
; } // Chunk into lunar cycles: whenever illumination crosses a local minimum (new moon ≈ < 5%) // For display, just show month-by-month rows with a continuous illumination bar. const today = format(new Date(), 'yyyy-MM-dd'); // Group days into months const monthMap = new Map(); for (const d of days) { const month = d.date.slice(0, 7); // "2026-04" if (!monthMap.has(month)) monthMap.set(month, []); monthMap.get(month)!.push(d); } return (
{'< 20% moon — prime narrowband'} {'20–50% — broadband OK'} {'> 50% — challenging'}
{Array.from(monthMap.entries()).map(([month, mDays]) => { // Find new moon dates in this month (local illumination minimum, < 5%) const newMoonDates = mDays.filter((d, i) => { const illum = d.moon_illumination ?? 0.5; const prev = mDays[i - 1]?.moon_illumination ?? illum; const next = mDays[i + 1]?.moon_illumination ?? illum; return illum < 0.05 && illum <= prev && illum <= next; }); // Days in this month ordered const sorted = [...mDays].sort((a, b) => a.date.localeCompare(b.date)); const daysInMonth = sorted.length; return (
{format(new Date(month + '-01'), 'MMM yyyy')} {/* Moon illumination bar */}
{sorted.map(d => { const illum = d.moon_illumination ?? 0; const isToday = d.date === today; const isNewMoon = newMoonDates.some(nm => nm.date === d.date); let bg: string; if (illum < 0.2) bg = 'rgba(61,186,114,0.35)'; else if (illum < 0.5) bg = 'rgba(232,131,42,0.30)'; else bg = 'rgba(224,82,82,0.25)'; return (
{/* New moon marker */} {isNewMoon && (
)} {/* Full moon marker */} {illum > 0.95 && (
)} {/* Illumination curve as height */}
); })} {/* Day count axis: show 1, 8, 15, 22, 28 */} {sorted.filter((_, i) => [0, 7, 14, 21, 27].includes(i)).map(d => { const idx = sorted.indexOf(d); const pct = (idx / daysInMonth) * 100; return (
{parseInt(d.date.slice(8))}
); })}
{/* New moon windows: date + top 3 emission targets */}
{newMoonDates.map(d => { const targets = nmTargetMap.get(d.date) ?? []; return (
● {d.date.slice(5)}
{targets.map(t => (
{t.common_name ?? t.name} {t.max_alt_deg != null && ( {t.max_alt_deg.toFixed(0)}° )}
))}
); })}
); })}
● = new moon  ·  vertical line = today  ·  hover a day for date + illumination %
); } export default function Calendar() { const [selectedDate, setSelectedDate] = useState(null); const [newMoonView, setNewMoonView] = useState(false); const { data: calData3 } = useCalendar(3); const { data: calData12 } = useCalendar(12); const { data: dateData } = useCalendarDate(selectedDate ?? ''); const calData = newMoonView ? calData12 : calData3; const today = new Date(); const dayMap = new Map( (calData?.days ?? []).map(d => [d.date, d]) ); const months = [today, new Date(today.getFullYear(), today.getMonth() + 1), new Date(today.getFullYear(), today.getMonth() + 2)]; return (

Calendar

{newMoonView ? ( ) : (
{months.map(month => { const monthStart = startOfMonth(month); const monthEnd = endOfMonth(month); const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); const firstDow = monthStart.getDay(); return (
{format(month, 'MMMM yyyy')}
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
{d}
))} {Array.from({ length: (firstDow + 6) % 7 }).map((_, i) => (
))} {days.map(day => { const dateStr = format(day, 'yyyy-MM-dd'); const info = dayMap.get(dateStr); const usable = info?.max_usable_min ?? 0; const moonIllum = info?.moon_illumination; const isSelected = selectedDate === dateStr; const isToday = dateStr === format(today, 'yyyy-MM-dd'); const isNarrowbandNight = moonIllum != null && moonIllum < 0.2; let bg = 'var(--bg-panel)'; if (usable > 240) bg = 'rgba(42,184,160,0.2)'; else if (usable > 60) bg = 'rgba(232,131,42,0.15)'; return (
setSelectedDate(isSelected ? null : dateStr)} style={{ background: isSelected ? 'var(--amber-glow)' : bg, border: `1px solid ${isSelected ? 'var(--amber)' : isNarrowbandNight ? 'var(--amber)' : isToday ? 'var(--text-lo)' : 'var(--border)'}`, borderRadius: 3, padding: '4px', cursor: 'pointer', minHeight: 56, position: 'relative', }} >
{format(day, 'd')} {moonIllum != null && ( )}
{usable > 0 && (
240 ? 'var(--teal)' : 'var(--amber)', borderRadius: 2, width: `${Math.min(100, (usable / 480) * 100)}%`, marginBottom: 2, }} /> )} {moonIllum != null && (
{Math.round(moonIllum * 100)}%
)}
); })}
); })}
{/* Side panel for selected date */} {selectedDate && (
{selectedDate}
{/* Moon + dark window */} {(() => { const illum = dateData?.moon_illumination ?? dayMap.get(selectedDate)?.moon_illumination; const info = dayMap.get(selectedDate); return illum != null ? (
{Math.round(illum * 100)}%
{illum < 0.2 && (
★ Prime narrowband night
)}
{info?.max_usable_min ? (
{Math.floor(info.max_usable_min / 60)}h {info.max_usable_min % 60}m dark
) : null}
) : null; })()} {/* Tonight summary */} {dateData?.tonight && (
{[ ['Dusk', dateData.tonight.astro_dusk_utc ? fmtTime(dateData.tonight.astro_dusk_utc) : '—'], ['Dawn', dateData.tonight.astro_dawn_utc ? fmtTime(dateData.tonight.astro_dawn_utc) : '—'], ['True dark', dateData.tonight.true_dark_start_utc && dateData.tonight.true_dark_end_utc ? `${fmtTime(dateData.tonight.true_dark_start_utc)} – ${fmtTime(dateData.tonight.true_dark_end_utc)}` : '—'], ['Moon', dateData.tonight.moon_phase_name ?? '—'], ].map(([label, val]) => ( ))}
{label} {val}
)} {/* Weather */} {dateData?.weather && (
{dateData.weather.go_nogo && ( {dateData.weather.go_nogo.toUpperCase()} )} {dateData.weather.temp_c != null && ( {dateData.weather.temp_c.toFixed(0)}°C )} {dateData.weather.cloudcover != null && ( Cloud {dateData.weather.cloudcover}/9 )} {dateData.weather.seeing != null && ( Seeing {dateData.weather.seeing}/8 )}
)}
Top Targets
{!dateData && (
No precomputed data for this date.
)} {dateData?.top_targets?.length === 0 && (
No visible targets.
)} {dateData?.top_targets?.map((t, i) => (
{i + 1}
{t.common_name ?? t.name}
{t.name}
{t.recommended_filter && ( {FILTER_LABELS[t.recommended_filter] ?? ''} )} {t.max_alt_deg?.toFixed(0)}°
))}
)}
)}
); }