Initial Commit

This commit is contained in:
2026-04-09 23:23:31 +02:00
commit a68677681f
94 changed files with 15170 additions and 0 deletions
+458
View File
@@ -0,0 +1,458 @@
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<string, string> = {
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<string, { id: string; name: string; common_name?: string; max_alt_deg?: number; recommended_filter?: string }[]>();
for (const w of nmData?.windows ?? []) {
nmTargetMap.set(w.date, w.top_targets);
}
if (days.length === 0) {
return <div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>Loading</div>;
}
// 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<string, CalDay[]>();
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 (
<div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12, fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--text-lo)' }}>
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(61,186,114,0.35)', borderRadius: 2, marginRight: 4 }} />{'< 20% moon — prime narrowband'}</span>
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(232,131,42,0.3)', borderRadius: 2, marginRight: 4 }} />{'2050% — broadband OK'}</span>
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(224,82,82,0.25)', borderRadius: 2, marginRight: 4 }} />{'> 50% — challenging'}</span>
</div>
</div>
{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 (
<div key={month} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--text-mid)',
width: 80,
flexShrink: 0,
}}>
{format(new Date(month + '-01'), 'MMM yyyy')}
</span>
{/* Moon illumination bar */}
<div style={{ flex: 1, position: 'relative', height: 28, display: 'flex' }}>
{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 (
<div key={d.date} title={`${d.date}${Math.round(illum * 100)}%`}
style={{
flex: 1,
background: bg,
borderLeft: isToday ? '2px solid var(--amber)' : '1px solid transparent',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{/* New moon marker */}
{isNewMoon && (
<div style={{
width: 4, height: 4,
borderRadius: '50%',
background: 'var(--text-hi)',
position: 'absolute',
top: 2,
}} />
)}
{/* Full moon marker */}
{illum > 0.95 && (
<div style={{
width: 4, height: 4,
borderRadius: '50%',
background: 'rgba(255,255,255,0.5)',
border: '1px solid rgba(255,255,255,0.7)',
position: 'absolute',
top: 2,
}} />
)}
{/* Illumination curve as height */}
<div style={{
position: 'absolute',
bottom: 0, left: 0, right: 0,
height: `${illum * 100}%`,
background: 'rgba(255,255,255,0.08)',
}} />
</div>
);
})}
{/* 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 (
<div key={`lbl-${d.date}`} style={{
position: 'absolute',
left: `${pct}%`,
bottom: -13,
fontFamily: 'var(--font-mono)',
fontSize: 8,
color: 'var(--text-lo)',
transform: 'translateX(-50%)',
}}>
{parseInt(d.date.slice(8))}
</div>
);
})}
</div>
{/* New moon windows: date + top 3 emission targets */}
<div style={{ width: 200, flexShrink: 0 }}>
{newMoonDates.map(d => {
const targets = nmTargetMap.get(d.date) ?? [];
return (
<div key={d.date} style={{ marginBottom: 4 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--amber)', marginBottom: 2 }}>
{d.date.slice(5)}
</div>
{targets.map(t => (
<div key={t.id} style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text-lo)', paddingLeft: 8 }}>
{t.common_name ?? t.name}
{t.max_alt_deg != null && (
<span style={{ color: 'var(--text-lo)', marginLeft: 4 }}>{t.max_alt_deg.toFixed(0)}°</span>
)}
</div>
))}
</div>
);
})}
</div>
</div>
</div>
);
})}
<div style={{ marginTop: 12, fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
= new moon &nbsp;·&nbsp; vertical line = today &nbsp;·&nbsp; hover a day for date + illumination %
</div>
</div>
);
}
export default function Calendar() {
const [selectedDate, setSelectedDate] = useState<string | null>(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<string, CalDay>(
(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 (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22 }}>Calendar</h1>
<button
onClick={() => { setNewMoonView(v => !v); setSelectedDate(null); }}
style={{
padding: '4px 12px',
borderRadius: 10,
border: `1px solid ${newMoonView ? 'var(--amber)' : 'var(--border)'}`,
background: newMoonView ? 'var(--amber-glow)' : 'var(--bg-panel)',
color: newMoonView ? 'var(--amber)' : 'var(--text-mid)',
fontFamily: 'var(--font-mono)',
fontSize: 11,
cursor: 'pointer',
}}
>
New Moon View
</button>
</div>
{newMoonView ? (
<NewMoonTimeline days={calData12?.days ?? []} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: selectedDate ? '2fr 1fr' : '1fr', gap: 20 }}>
<div>
{months.map(month => {
const monthStart = startOfMonth(month);
const monthEnd = endOfMonth(month);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const firstDow = monthStart.getDay();
return (
<div key={month.toISOString()} style={{ marginBottom: 28 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontWeight: 700, marginBottom: 10, color: 'var(--text-hi)' }}>
{format(month, 'MMMM yyyy')}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 3 }}>
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
<div key={d} style={{ textAlign: 'center', fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text-lo)', paddingBottom: 4 }}>
{d}
</div>
))}
{Array.from({ length: (firstDow + 6) % 7 }).map((_, i) => (
<div key={`e${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 (
<div
key={dateStr}
onClick={() => 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',
}}
>
<div style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: isToday ? 'var(--amber)' : 'var(--text-mid)',
marginBottom: 2,
fontWeight: isToday ? 700 : 400,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span>{format(day, 'd')}</span>
{moonIllum != null && (
<MoonPhaseIcon illumination={moonIllum} size={12} />
)}
</div>
{usable > 0 && (
<div style={{
height: 3,
background: usable > 240 ? 'var(--teal)' : 'var(--amber)',
borderRadius: 2,
width: `${Math.min(100, (usable / 480) * 100)}%`,
marginBottom: 2,
}} />
)}
{moonIllum != null && (
<div style={{
fontFamily: 'var(--font-mono)',
fontSize: 9,
color: isNarrowbandNight ? 'var(--amber)' : 'var(--text-lo)',
}}>
{Math.round(moonIllum * 100)}%
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
{/* Side panel for selected date */}
{selectedDate && (
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: 16, height: 'fit-content', position: 'sticky', top: 8 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontWeight: 700, marginBottom: 12 }}>
{selectedDate}
</div>
{/* Moon + dark window */}
{(() => {
const illum = dateData?.moon_illumination ?? dayMap.get(selectedDate)?.moon_illumination;
const info = dayMap.get(selectedDate);
return illum != null ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<MoonPhaseIcon illumination={illum} size={28} />
<div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text-hi)', fontWeight: 700 }}>
{Math.round(illum * 100)}%
</div>
{illum < 0.2 && (
<div style={{ fontSize: 10, color: 'var(--amber)', fontFamily: 'var(--font-mono)' }}>
Prime narrowband night
</div>
)}
</div>
{info?.max_usable_min ? (
<div style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--teal)' }}>
{Math.floor(info.max_usable_min / 60)}h {info.max_usable_min % 60}m dark
</div>
) : null}
</div>
) : null;
})()}
{/* Tonight summary */}
{dateData?.tonight && (
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 10px', marginBottom: 10 }}>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<tbody>
{[
['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]) => (
<tr key={label}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', paddingBottom: 3, width: 70 }}>{label}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)' }}>{val}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Weather */}
{dateData?.weather && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10, padding: '6px 10px', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4 }}>
{dateData.weather.go_nogo && (
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
fontWeight: 700,
color: dateData.weather.go_nogo === 'go' ? 'var(--good)' : dateData.weather.go_nogo === 'marginal' ? 'var(--warn)' : 'var(--danger)',
}}>
{dateData.weather.go_nogo.toUpperCase()}
</span>
)}
{dateData.weather.temp_c != null && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-mid)' }}>
{dateData.weather.temp_c.toFixed(0)}°C
</span>
)}
{dateData.weather.cloudcover != null && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
Cloud {dateData.weather.cloudcover}/9
</span>
)}
{dateData.weather.seeing != null && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
Seeing {dateData.weather.seeing}/8
</span>
)}
</div>
)}
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginBottom: 8, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
Top Targets
</div>
{!dateData && (
<div style={{ color: 'var(--text-lo)', fontSize: 12 }}>No precomputed data for this date.</div>
)}
{dateData?.top_targets?.length === 0 && (
<div style={{ color: 'var(--text-lo)', fontSize: 12 }}>No visible targets.</div>
)}
{dateData?.top_targets?.map((t, i) => (
<div key={t.id} style={{
display: 'flex',
gap: 8,
alignItems: 'center',
padding: '5px 0',
borderBottom: '1px solid var(--border)',
}}>
<span style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 10, width: 16 }}>{i + 1}</span>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)' }}>
{t.common_name ?? t.name}
</div>
<div style={{ fontSize: 10, color: 'var(--text-lo)' }}>{t.name}</div>
</div>
{t.recommended_filter && (
<span className={`filter-pill ${t.recommended_filter}`} style={{ fontSize: 9 }}>
{FILTER_LABELS[t.recommended_filter] ?? ''}
</span>
)}
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--good)' }}>
{t.max_alt_deg?.toFixed(0)}°
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}