Add weather sync job, API endpoints and settings UI
Fetches daily ERA5 weather from Open-Meteo (no API key). Configurable location, timezone and sync time via Settings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fc15bc5d9a
commit
5fbf155f62
4 changed files with 671 additions and 1 deletions
|
|
@ -80,7 +80,7 @@ const badgeStyle = (status: 'success' | 'warning' | 'error' | 'info' = 'info'):
|
|||
}, statuses[status])
|
||||
}
|
||||
|
||||
type SettingsPage = 'newbook' | 'resos' | 'database' | 'special-dates' | 'budget' | 'tax-rates' | 'forecast-snapshots' | 'backup' | 'api-keys' | 'ai-insights'
|
||||
type SettingsPage = 'newbook' | 'resos' | 'database' | 'special-dates' | 'budget' | 'tax-rates' | 'forecast-snapshots' | 'backup' | 'api-keys' | 'ai-insights' | 'weather'
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const [activePage, setActivePage] = useState<SettingsPage>('newbook')
|
||||
|
|
@ -93,6 +93,7 @@ const Settings: React.FC = () => {
|
|||
{ id: 'tax-rates', label: 'Tax Rates' },
|
||||
{ id: 'forecast-snapshots', label: 'Forecast Snapshots' },
|
||||
{ id: 'ai-insights', label: 'AI Insights' },
|
||||
{ id: 'weather', label: 'Weather' },
|
||||
{ id: 'api-keys', label: 'API Keys' },
|
||||
{ id: 'backup', label: 'Backup & Restore' },
|
||||
{ id: 'database', label: 'Database Browser' },
|
||||
|
|
@ -128,6 +129,7 @@ const Settings: React.FC = () => {
|
|||
{activePage === 'backup' && <BackupPage />}
|
||||
{activePage === 'database' && <DatabasePage />}
|
||||
{activePage === 'ai-insights' && <AIInsightsPage />}
|
||||
{activePage === 'weather' && <WeatherPage />}
|
||||
{activePage === 'api-keys' && <ApiKeysPage />}
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -7235,4 +7237,279 @@ const styles: Record<string, React.CSSProperties> = {
|
|||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WEATHER PAGE
|
||||
// ============================================================
|
||||
|
||||
interface WeatherSettings {
|
||||
enabled: boolean
|
||||
location_name: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
timezone: string
|
||||
sync_time: string
|
||||
}
|
||||
|
||||
interface WeatherStatus {
|
||||
coverage: { first_date: string | null; last_date: string | null; total_days: number }
|
||||
last_sync: { completed_at: string | null; status: string | null; records_fetched: number | null; date_from: string | null; date_to: string | null }
|
||||
}
|
||||
|
||||
const WeatherPage: React.FC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [locationName, setLocationName] = useState('')
|
||||
const [latitude, setLatitude] = useState('')
|
||||
const [longitude, setLongitude] = useState('')
|
||||
const [timezone, setTimezone] = useState('Europe/London')
|
||||
const [syncTime, setSyncTime] = useState('05:15')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [syncStatus, setSyncStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle')
|
||||
const [backfillFrom, setBackfillFrom] = useState('2020-01-01')
|
||||
const [backfillTo, setBackfillTo] = useState(new Date().toISOString().slice(0, 10))
|
||||
const [backfillStatus, setBackfillStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle')
|
||||
|
||||
const { data: settings, isLoading } = useQuery<WeatherSettings>({
|
||||
queryKey: ['weather-settings'],
|
||||
queryFn: async () => {
|
||||
const r = await fetch('/forecasting/api/config/settings/weather')
|
||||
if (!r.ok) throw new Error('Failed to load weather settings')
|
||||
return r.json()
|
||||
},
|
||||
})
|
||||
|
||||
const { data: status } = useQuery<WeatherStatus>({
|
||||
queryKey: ['weather-status'],
|
||||
queryFn: async () => {
|
||||
const r = await fetch('/forecasting/api/sync/weather/status')
|
||||
if (!r.ok) return null
|
||||
return r.json()
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setLocationName(settings.location_name)
|
||||
setLatitude(String(settings.latitude))
|
||||
setLongitude(String(settings.longitude))
|
||||
setTimezone(settings.timezone)
|
||||
setSyncTime(settings.sync_time)
|
||||
setEnabled(settings.enabled)
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
const r = await fetch('/forecasting/api/config/settings/weather', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled,
|
||||
location_name: locationName,
|
||||
latitude: parseFloat(latitude),
|
||||
longitude: parseFloat(longitude),
|
||||
timezone,
|
||||
sync_time: syncTime,
|
||||
}),
|
||||
})
|
||||
if (r.ok) {
|
||||
setSaveStatus('saved')
|
||||
queryClient.invalidateQueries({ queryKey: ['weather-settings'] })
|
||||
setTimeout(() => setSaveStatus('idle'), 3000)
|
||||
} else {
|
||||
setSaveStatus('error')
|
||||
setTimeout(() => setSaveStatus('idle'), 3000)
|
||||
}
|
||||
} catch {
|
||||
setSaveStatus('error')
|
||||
setTimeout(() => setSaveStatus('idle'), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncStatus('running')
|
||||
try {
|
||||
const r = await fetch('/forecasting/api/sync/weather', { method: 'POST' })
|
||||
if (r.ok) {
|
||||
setSyncStatus('done')
|
||||
queryClient.invalidateQueries({ queryKey: ['weather-status'] })
|
||||
} else {
|
||||
setSyncStatus('error')
|
||||
}
|
||||
} catch {
|
||||
setSyncStatus('error')
|
||||
}
|
||||
setTimeout(() => setSyncStatus('idle'), 5000)
|
||||
}
|
||||
|
||||
const handleBackfill = async () => {
|
||||
setBackfillStatus('running')
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/forecasting/api/sync/weather/backfill?from_date=${backfillFrom}&to_date=${backfillTo}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (r.ok) {
|
||||
setBackfillStatus('done')
|
||||
queryClient.invalidateQueries({ queryKey: ['weather-status'] })
|
||||
} else {
|
||||
setBackfillStatus('error')
|
||||
}
|
||||
} catch {
|
||||
setBackfillStatus('error')
|
||||
}
|
||||
setTimeout(() => setBackfillStatus('idle'), 5000)
|
||||
}
|
||||
|
||||
if (isLoading) return <div style={{ padding: spacing.xl, color: colors.textMuted }}>Loading…</div>
|
||||
|
||||
const fieldStyle: React.CSSProperties = {
|
||||
width: '100%', padding: `${spacing.sm} ${spacing.md}`,
|
||||
border: `1px solid ${colors.border}`, borderRadius: radius.md,
|
||||
fontSize: typography.sm, color: colors.text, background: colors.surface,
|
||||
boxSizing: 'border-box',
|
||||
}
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block', fontSize: typography.sm, fontWeight: typography.medium,
|
||||
color: colors.textSecondary, marginBottom: spacing.xs,
|
||||
}
|
||||
const rowStyle: React.CSSProperties = { marginBottom: spacing.md }
|
||||
const halfRowStyle: React.CSSProperties = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: spacing.md, marginBottom: spacing.md }
|
||||
|
||||
return (
|
||||
<div style={{ padding: spacing.xl, maxWidth: 680 }}>
|
||||
<h2 style={{ fontSize: typography.xxl, fontWeight: typography.semibold, color: colors.text, marginBottom: spacing.xs }}>
|
||||
Weather Data
|
||||
</h2>
|
||||
<p style={{ fontSize: typography.sm, color: colors.textMuted, marginBottom: spacing.xl }}>
|
||||
Daily weather fetched from Open-Meteo (ERA5 archive — free, no API key).
|
||||
Set the coordinates for the hotel location; each stack uses its own location.
|
||||
</p>
|
||||
|
||||
{/* Coverage status */}
|
||||
{status && (
|
||||
<div style={{ background: colors.infoBg, border: `1px solid ${colors.border}`, borderRadius: radius.md, padding: spacing.md, marginBottom: spacing.xl }}>
|
||||
<div style={{ display: 'flex', gap: spacing.xl, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: typography.xs, color: colors.textMuted, marginBottom: spacing.xs }}>Coverage</div>
|
||||
<div style={{ fontSize: typography.sm, fontWeight: typography.medium, color: colors.text }}>
|
||||
{status.coverage.first_date ?? '—'} → {status.coverage.last_date ?? '—'}
|
||||
</div>
|
||||
<div style={{ fontSize: typography.xs, color: colors.textMuted }}>{status.coverage.total_days.toLocaleString()} days</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: typography.xs, color: colors.textMuted, marginBottom: spacing.xs }}>Last sync</div>
|
||||
<div style={{ fontSize: typography.sm, color: colors.text }}>
|
||||
{status.last_sync.completed_at ? new Date(status.last_sync.completed_at).toLocaleString() : 'Never'}
|
||||
</div>
|
||||
{status.last_sync.status && (
|
||||
<span style={badgeStyle(status.last_sync.status === 'success' ? 'success' : 'error')}>
|
||||
{status.last_sync.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
<div style={{ background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: radius.lg, padding: spacing.lg, marginBottom: spacing.lg }}>
|
||||
<h3 style={{ fontSize: typography.base, fontWeight: typography.semibold, color: colors.text, marginBottom: spacing.md }}>Location</h3>
|
||||
|
||||
<div style={rowStyle}>
|
||||
<label style={labelStyle}>Location name</label>
|
||||
<input style={fieldStyle} value={locationName} onChange={e => setLocationName(e.target.value)} placeholder="e.g. Stow on the Wold, GL54 1JX" />
|
||||
</div>
|
||||
|
||||
<div style={halfRowStyle}>
|
||||
<div>
|
||||
<label style={labelStyle}>Latitude</label>
|
||||
<input style={fieldStyle} type="number" step="0.0001" value={latitude} onChange={e => setLatitude(e.target.value)} placeholder="51.9253" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Longitude</label>
|
||||
<input style={fieldStyle} type="number" step="0.0001" value={longitude} onChange={e => setLongitude(e.target.value)} placeholder="-1.7272" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={rowStyle}>
|
||||
<label style={labelStyle}>Timezone (IANA)</label>
|
||||
<input style={fieldStyle} value={timezone} onChange={e => setTimezone(e.target.value)} placeholder="Europe/London" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule */}
|
||||
<div style={{ background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: radius.lg, padding: spacing.lg, marginBottom: spacing.lg }}>
|
||||
<h3 style={{ fontSize: typography.base, fontWeight: typography.semibold, color: colors.text, marginBottom: spacing.md }}>Schedule</h3>
|
||||
|
||||
<div style={halfRowStyle}>
|
||||
<div>
|
||||
<label style={labelStyle}>Sync time (HH:MM)</label>
|
||||
<input style={fieldStyle} value={syncTime} onChange={e => setSyncTime(e.target.value)} placeholder="05:15" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: spacing.xs }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: spacing.sm, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
|
||||
<span style={{ fontSize: typography.sm, color: colors.text }}>Enable daily sync</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: typography.xs, color: colors.textMuted, margin: 0 }}>
|
||||
Daily sync fetches the last 7 days — ERA5 data is updated 5 days behind real-time so recent values may be revised.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
style={buttonStyle('primary')}
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === 'saving'}
|
||||
>
|
||||
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'saved' ? 'Saved' : saveStatus === 'error' ? 'Error' : 'Save settings'}
|
||||
</button>
|
||||
|
||||
{/* Manual sync */}
|
||||
<div style={{ borderTop: `1px solid ${colors.border}`, marginTop: spacing.xl, paddingTop: spacing.xl }}>
|
||||
<h3 style={{ fontSize: typography.base, fontWeight: typography.semibold, color: colors.text, marginBottom: spacing.sm }}>Manual sync</h3>
|
||||
<p style={{ fontSize: typography.sm, color: colors.textMuted, marginBottom: spacing.md }}>
|
||||
Fetches the last 7 days for the configured location.
|
||||
</p>
|
||||
<button
|
||||
style={buttonStyle('outline')}
|
||||
onClick={handleSync}
|
||||
disabled={syncStatus === 'running'}
|
||||
>
|
||||
{syncStatus === 'running' ? 'Syncing…' : syncStatus === 'done' ? 'Done' : syncStatus === 'error' ? 'Error' : 'Sync last 7 days'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Backfill */}
|
||||
<div style={{ borderTop: `1px solid ${colors.border}`, marginTop: spacing.xl, paddingTop: spacing.xl }}>
|
||||
<h3 style={{ fontSize: typography.base, fontWeight: typography.semibold, color: colors.text, marginBottom: spacing.sm }}>Backfill</h3>
|
||||
<p style={{ fontSize: typography.sm, color: colors.textMuted, marginBottom: spacing.md }}>
|
||||
Open-Meteo has ERA5 data from 1940-01-01. Use this to populate any date range.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: spacing.md, alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>From date</label>
|
||||
<input style={fieldStyle} type="date" value={backfillFrom} onChange={e => setBackfillFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>To date</label>
|
||||
<input style={fieldStyle} type="date" value={backfillTo} onChange={e => setBackfillTo(e.target.value)} />
|
||||
</div>
|
||||
<button
|
||||
style={mergeStyles(buttonStyle('secondary'), { whiteSpace: 'nowrap' })}
|
||||
onClick={handleBackfill}
|
||||
disabled={backfillStatus === 'running'}
|
||||
>
|
||||
{backfillStatus === 'running' ? 'Running…' : backfillStatus === 'done' ? 'Done' : backfillStatus === 'error' ? 'Error' : 'Run backfill'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue