236 lines
12 KiB
TypeScript
236 lines
12 KiB
TypeScript
import { useState } from 'react';
|
||
import { useTonight } from '../hooks/useTonight';
|
||
import { useWeather, useForecast } from '../hooks/useWeather';
|
||
import { useTargets } from '../hooks/useTargets';
|
||
import { useStats } from '../hooks/useStats';
|
||
import GoNogo from '../components/weather/GoNogo';
|
||
import DewAlert from '../components/weather/DewAlert';
|
||
import MoonPhaseIcon from '../components/sky/MoonPhaseIcon';
|
||
import DetailDrawer from '../components/targets/DetailDrawer';
|
||
import type { Target } from '../api/types';
|
||
|
||
const FILTER_LABELS: Record<string, string> = {
|
||
sv220: 'HaOIII', c2: 'SII/OIII', sv260: 'LP', uvir: 'UV/IR',
|
||
};
|
||
const CC_LABELS: Record<number, string> = {
|
||
1: 'Clear', 2: 'Clear', 3: 'Mostly clear', 4: 'Partly cloudy',
|
||
5: 'Partly cloudy', 6: 'Cloudy', 7: 'Mostly cloudy', 8: 'Overcast', 9: 'Overcast',
|
||
};
|
||
const CC_COLOR = (n: number) => n <= 2 ? 'var(--good)' : n <= 4 ? 'var(--teal)' : n <= 6 ? 'var(--warn)' : 'var(--danger)';
|
||
|
||
function fmtTime(utc?: string): string {
|
||
if (!utc) return '—';
|
||
return new Date(utc).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
||
}
|
||
function fmtDuration(min?: number): string {
|
||
if (!min) return '—';
|
||
const h = Math.floor(min / 60);
|
||
const m = min % 60;
|
||
return `${h}h ${m < 10 ? '0' : ''}${m}m`;
|
||
}
|
||
function fmtIntTotal(min: number): string {
|
||
if (min < 60) return `${min} min`;
|
||
const h = (min / 60).toFixed(1);
|
||
return `${h} h`;
|
||
}
|
||
|
||
export default function Dashboard() {
|
||
const { data: tonight } = useTonight();
|
||
const { data: weather } = useWeather();
|
||
const { data: forecast } = useForecast();
|
||
const { data: targets } = useTargets({ tonight: true, limit: 5 });
|
||
const { data: stats } = useStats();
|
||
const [expandedTarget, setExpandedTarget] = useState<Target | null>(null);
|
||
|
||
const moonPct = tonight?.moon_illumination != null
|
||
? `${Math.round(tonight.moon_illumination * 100)}%`
|
||
: '—';
|
||
|
||
// Next 4 forecast slots for mini weather bar (3h each = 12h ahead)
|
||
const slots = (forecast as { dataseries?: { cloudcover?: number; seeing?: number; timepoint?: number }[] })?.dataseries?.slice(0, 8) ?? [];
|
||
|
||
return (
|
||
<div style={{ padding: '24px 28px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
|
||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, color: 'var(--text-hi)', letterSpacing: '0.04em' }}>
|
||
Dashboard
|
||
</h1>
|
||
{tonight?.date && (
|
||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-lo)' }}>
|
||
{tonight.date}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Dew alert banner */}
|
||
{weather?.dew_alert && (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<DewAlert level={weather.dew_alert} temp={weather.temp_c} dewPoint={weather.dew_point_c} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Stat cards row */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 20 }}>
|
||
{/* Go/No-go */}
|
||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>Tonight</div>
|
||
<GoNogo status={weather?.go_nogo} />
|
||
{weather?.temp_c != null && (
|
||
<div style={{ marginTop: 8, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||
{weather.temp_c.toFixed(1)}°C · {weather.humidity_pct?.toFixed(0)}% RH
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Moon */}
|
||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>Moon</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
{tonight?.moon_illumination != null && <MoonPhaseIcon illumination={tonight.moon_illumination} size={32} />}
|
||
<div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 22, fontWeight: 600, color: 'var(--text-hi)', lineHeight: 1 }}>{moonPct}</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 3 }}>{tonight?.moon_phase_name ?? '—'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* True dark */}
|
||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>True Dark</div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 600, color: 'var(--teal)', lineHeight: 1 }}>
|
||
{fmtDuration(tonight?.true_dark_minutes)}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||
{tonight?.true_dark_start_utc
|
||
? `${fmtTime(tonight.true_dark_start_utc)} – ${fmtTime(tonight.true_dark_end_utc)}`
|
||
: 'No full dark tonight'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats summary */}
|
||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>Logbook</div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 600, color: 'var(--amber)', lineHeight: 1 }}>
|
||
{stats ? fmtIntTotal(stats.total_integration_min) : '—'}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||
{stats ? `${stats.total_sessions} sessions · ${stats.objects_with_keeper} keepers` : 'No data yet'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tonight timing + top targets + forecast */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||
|
||
{/* Tonight timing */}
|
||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>Tonight's Window</div>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<tbody>
|
||
{[
|
||
['Dusk', fmtTime(tonight?.astro_dusk_utc)],
|
||
['Dawn', fmtTime(tonight?.astro_dawn_utc)],
|
||
['Moon rise', fmtTime(tonight?.moon_rise_utc)],
|
||
['Moon set', fmtTime(tonight?.moon_set_utc)],
|
||
['Dark start', fmtTime(tonight?.true_dark_start_utc)],
|
||
['Dark end', fmtTime(tonight?.true_dark_end_utc)],
|
||
].map(([label, value]) => (
|
||
<tr key={label}>
|
||
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, paddingBottom: 4, paddingRight: 8 }}>{label}</td>
|
||
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11, textAlign: 'right' }}>{value}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Top targets */}
|
||
<div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>Top Targets Tonight</div>
|
||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||
{!targets?.items?.length && (
|
||
<div style={{ padding: 16, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||
Catalog loading…
|
||
</div>
|
||
)}
|
||
{targets?.items?.map((t, i) => (
|
||
<div key={t.id}>
|
||
<div
|
||
onClick={() => setExpandedTarget(expandedTarget?.id === t.id ? null : t)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
padding: '8px 14px',
|
||
borderBottom: '1px solid var(--border)',
|
||
gap: 10,
|
||
cursor: 'pointer',
|
||
background: expandedTarget?.id === t.id ? 'var(--bg-hover)' : 'transparent',
|
||
transition: 'background 0.1s',
|
||
}}
|
||
>
|
||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', width: 14 }}>{i + 1}</span>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{t.common_name ?? t.name}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-lo)' }}>
|
||
{t.name} · {t.usable_min ? `${t.usable_min}min` : '—'}
|
||
</div>
|
||
</div>
|
||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600, color: (t.max_alt_deg ?? 0) >= 30 ? 'var(--good)' : 'var(--warn)' }}>
|
||
{t.max_alt_deg?.toFixed(0)}°
|
||
</span>
|
||
{t.recommended_filter && (
|
||
<span className={`filter-pill ${t.recommended_filter}`} style={{ fontSize: 9 }}>
|
||
{FILTER_LABELS[t.recommended_filter] ?? t.recommended_filter.toUpperCase()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{expandedTarget?.id === t.id && <DetailDrawer target={t} />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Forecast mini bars */}
|
||
<div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>24h Forecast</div>
|
||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '12px 14px' }}>
|
||
{slots.length === 0 && (
|
||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>No forecast data</div>
|
||
)}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{slots.map((slot, i) => {
|
||
const cc = slot.cloudcover ?? 5;
|
||
const seeing = slot.seeing ?? 5;
|
||
const hoursAhead = (i + 1) * 3;
|
||
const label = `+${hoursAhead}h`;
|
||
return (
|
||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', width: 28 }}>{label}</span>
|
||
<div style={{ flex: 1, background: 'var(--bg-deep)', borderRadius: 2, height: 6, overflow: 'hidden' }}>
|
||
<div style={{
|
||
height: '100%',
|
||
width: `${((9 - cc) / 8) * 100}%`,
|
||
background: CC_COLOR(cc),
|
||
borderRadius: 2,
|
||
transition: 'width 0.3s',
|
||
}} />
|
||
</div>
|
||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: CC_COLOR(cc), width: 80 }}>
|
||
{CC_LABELS[cc] ?? '—'}
|
||
</span>
|
||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: seeing <= 3 ? 'var(--good)' : seeing <= 5 ? 'var(--warn)' : 'var(--danger)', width: 24, textAlign: 'right' }}>
|
||
{['', '0.5″', '0.75″', '1.0″', '1.25″', '1.5″', '2.0″', '2.5″', '>3″'][seeing] ?? '—'}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|